mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
Session titles: when `models.session_summary` is unset, Grok Build (verified on 1.0.50) generates titles with the built-in `grok-4.6` model but sends the request to the default model table's endpoint, so third-party upstreams reject it and title generation fails. Switching never wrote this key, so fixing the row templates alone would not reach live. The Grok switch patch now makes session_summary follow the provider when it is unset or names a table CC Switch wrote (the target, the write record, or a table retired in this switch): it points at the target table, or is removed when switching to the official account. A user's own choice (a built-in model or their own table) is left alone. Deep links: a Grok Build link carrying a config.toml no longer gets rebuilt into a fixed template that dropped the profile's display name, description, API backend and context window. The table named by `[models] default` is kept through an allowlist of Grok 1.0.50 profile fields; the endpoint, key and model come from the link parameters. Links whose table uses auth_provider, env_http_headers or mtls_cert_dir are rejected, and env_key is still dropped. Only one model table is imported, since a provider owns one table in live. Links without a config keep the old template byte for byte. Fixes #7003 Refs #6457
1526 lines
53 KiB
Rust
1526 lines
53 KiB
Rust
//! Deep link module tests
|
||
|
||
use super::mcp::parse_mcp_apps;
|
||
use super::parser::parse_deeplink_url;
|
||
use super::prompt::import_prompt_from_deeplink;
|
||
use super::provider::parse_and_merge_config;
|
||
use super::utils::{infer_homepage_from_endpoint, validate_url};
|
||
use super::DeepLinkImportRequest;
|
||
use crate::AppType;
|
||
use crate::{store::AppState, Database};
|
||
use base64::prelude::*;
|
||
use std::{env, ffi::OsString, sync::Arc};
|
||
|
||
struct TestHomeGuard {
|
||
_dir: tempfile::TempDir,
|
||
original_home: Option<OsString>,
|
||
original_userprofile: Option<OsString>,
|
||
original_test_home: Option<OsString>,
|
||
}
|
||
|
||
impl TestHomeGuard {
|
||
fn new() -> Self {
|
||
let dir = tempfile::tempdir().expect("create isolated test home");
|
||
let original_home = env::var_os("HOME");
|
||
let original_userprofile = env::var_os("USERPROFILE");
|
||
let original_test_home = env::var_os("CC_SWITCH_TEST_HOME");
|
||
|
||
env::set_var("HOME", dir.path());
|
||
env::set_var("USERPROFILE", dir.path());
|
||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||
|
||
Self {
|
||
_dir: dir,
|
||
original_home,
|
||
original_userprofile,
|
||
original_test_home,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for TestHomeGuard {
|
||
fn drop(&mut self) {
|
||
match &self.original_test_home {
|
||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
match &self.original_userprofile {
|
||
Some(value) => env::set_var("USERPROFILE", value),
|
||
None => env::remove_var("USERPROFILE"),
|
||
}
|
||
match &self.original_home {
|
||
Some(value) => env::set_var("HOME", value),
|
||
None => env::remove_var("HOME"),
|
||
}
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Parser Tests
|
||
// =============================================================================
|
||
|
||
#[test]
|
||
fn test_parse_valid_claude_deeplink() {
|
||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test-123&icon=claude";
|
||
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
assert_eq!(request.version, "v1");
|
||
assert_eq!(request.resource, "provider");
|
||
assert_eq!(request.app, Some("claude".to_string()));
|
||
assert_eq!(request.name, Some("Test Provider".to_string()));
|
||
assert_eq!(request.homepage, Some("https://example.com".to_string()));
|
||
assert_eq!(
|
||
request.endpoint,
|
||
Some("https://api.example.com".to_string())
|
||
);
|
||
assert_eq!(request.api_key, Some("sk-test-123".to_string()));
|
||
assert_eq!(request.icon, Some("claude".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_deeplink_with_notes() {
|
||
let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes";
|
||
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
assert_eq!(request.notes, Some("Test notes".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn pi_provider_deeplink_is_not_a_second_add_provider_entry() {
|
||
let url = "ccswitch://v1/import?resource=provider&app=pi&name=Pi";
|
||
assert!(parse_deeplink_url(url).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_grokbuild_provider() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let url = "ccswitch://v1/import?resource=provider&app=grokbuild&name=Grok%20Relay&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=secret&model=grok-4.5";
|
||
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||
assert_eq!(request.name.as_deref(), Some("Grok Relay"));
|
||
assert_eq!(
|
||
request.endpoint.as_deref(),
|
||
Some("https://api.example.com/v1")
|
||
);
|
||
assert_eq!(request.api_key.as_deref(), Some("secret"));
|
||
assert_eq!(request.model.as_deref(), Some("grok-4.5"));
|
||
|
||
let provider = build_provider_from_request(&AppType::GrokBuild, &request).unwrap();
|
||
let config = provider.settings_config["config"].as_str().unwrap();
|
||
let document = config.parse::<toml::Value>().unwrap();
|
||
let model = &document["model"]["grok-4.5"];
|
||
|
||
assert_eq!(document["models"]["default"].as_str(), Some("grok-4.5"));
|
||
assert_eq!(
|
||
model["base_url"].as_str(),
|
||
Some("https://api.example.com/v1")
|
||
);
|
||
assert_eq!(model["name"].as_str(), Some("Grok Relay"));
|
||
assert_eq!(model["api_key"].as_str(), Some("secret"));
|
||
assert_eq!(model["api_backend"].as_str(), Some("responses"));
|
||
assert_eq!(model["context_window"].as_integer(), Some(500_000));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_invalid_scheme() {
|
||
let url = "https://v1/import?resource=provider&app=claude&name=Test";
|
||
|
||
let result = parse_deeplink_url(url);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().to_string().contains("Invalid scheme"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_unsupported_version() {
|
||
let url = "ccswitch://v2/import?resource=provider&app=claude&name=Test";
|
||
|
||
let result = parse_deeplink_url(url);
|
||
assert!(result.is_err());
|
||
assert!(result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("Unsupported protocol version"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_missing_required_field() {
|
||
// Name is still required even in v3.8+ (only homepage/endpoint/apiKey are optional)
|
||
let url = "ccswitch://v1/import?resource=provider&app=claude";
|
||
|
||
let result = parse_deeplink_url(url);
|
||
assert!(result.is_err());
|
||
assert!(result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("Missing 'name' parameter"));
|
||
}
|
||
|
||
/// configUrl 没实现,必须在解析阶段就报错:放行的话,用户点了确认导入才失败
|
||
#[test]
|
||
fn test_parse_rejects_config_url_up_front() {
|
||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&configUrl=https%3A%2F%2Fexample.com%2Fc.json";
|
||
|
||
let err = parse_deeplink_url(url).unwrap_err();
|
||
assert!(err.to_string().contains("configUrl"), "{err}");
|
||
}
|
||
|
||
/// 下游 parse_mcp_apps 与 McpService 都支持 Pi / MiniMax Code,解析层不能先把它们拦掉
|
||
#[test]
|
||
fn test_parse_mcp_deeplink_accepts_pi_and_mcode() {
|
||
let config = BASE64_URL_SAFE_NO_PAD.encode(r#"{"mcpServers":{"fetch":{"command":"uvx"}}}"#);
|
||
let url = format!("ccswitch://v1/import?resource=mcp&apps=claude,pi,mcode&config={config}");
|
||
|
||
let request = parse_deeplink_url(&url).expect("pi and mcode are valid MCP targets");
|
||
let apps = parse_mcp_apps(request.apps.as_deref().unwrap()).unwrap();
|
||
assert!(apps.claude && apps.pi && apps.mcode);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_prompt_deeplink_accepts_mcode() {
|
||
let url = "ccswitch://v1/import?resource=prompt&app=mcode&name=Rules&content=IyBoaQ%3D%3D";
|
||
|
||
let request = parse_deeplink_url(url).expect("MiniMax Code has a prompt panel");
|
||
assert_eq!(request.app.as_deref(), Some("mcode"));
|
||
}
|
||
|
||
/// 读取不清除,按编号确认才清除:前端读到后、显示前被打断(effect 重建、窗口销毁),
|
||
/// 下次读取仍能拿到;期间来了新链接,迟到的旧确认不能把新链接清掉
|
||
#[test]
|
||
#[serial_test::serial]
|
||
fn test_pending_deeplink_survives_peek_and_clears_only_on_matching_ack() {
|
||
use super::{
|
||
ack_pending_deeplink, peek_pending_deeplink, set_pending_deeplink, PendingDeepLink,
|
||
};
|
||
|
||
let link = |name: &str| PendingDeepLink::Import {
|
||
request: Box::new(
|
||
parse_deeplink_url(&format!(
|
||
"ccswitch://v1/import?resource=provider&app=claude&name={name}"
|
||
))
|
||
.expect("valid link"),
|
||
),
|
||
};
|
||
|
||
set_pending_deeplink(link("A"));
|
||
let first = peek_pending_deeplink().expect("pending link");
|
||
let json = serde_json::to_value(&first).unwrap();
|
||
assert_eq!(json["link"]["kind"], "import");
|
||
assert_eq!(json["link"]["request"]["name"], "A");
|
||
assert_eq!(
|
||
peek_pending_deeplink().map(|entry| entry.id),
|
||
Some(first.id),
|
||
"peek must not clear the link"
|
||
);
|
||
|
||
set_pending_deeplink(link("B"));
|
||
ack_pending_deeplink(first.id);
|
||
let second = peek_pending_deeplink().expect("a stale ack must not clear the newer link");
|
||
assert_ne!(second.id, first.id);
|
||
|
||
ack_pending_deeplink(second.id);
|
||
assert!(peek_pending_deeplink().is_none());
|
||
}
|
||
|
||
// =============================================================================
|
||
// Utils Tests
|
||
// =============================================================================
|
||
|
||
#[test]
|
||
fn test_validate_invalid_url() {
|
||
let result = validate_url("not-a-url", "test");
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_invalid_scheme() {
|
||
let result = validate_url("ftp://example.com", "test");
|
||
assert!(result.is_err());
|
||
assert!(result
|
||
.unwrap_err()
|
||
.to_string()
|
||
.contains("must be http or https"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_infer_homepage() {
|
||
assert_eq!(
|
||
infer_homepage_from_endpoint("https://api.anthropic.com/v1"),
|
||
Some("https://anthropic.com".to_string())
|
||
);
|
||
assert_eq!(
|
||
infer_homepage_from_endpoint("https://api-test.company.com/v1"),
|
||
Some("https://test.company.com".to_string())
|
||
);
|
||
assert_eq!(
|
||
infer_homepage_from_endpoint("https://example.com"),
|
||
Some("https://example.com".to_string())
|
||
);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Provider Tests
|
||
// =============================================================================
|
||
|
||
#[test]
|
||
fn test_build_gemini_provider_with_model() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("gemini".to_string()),
|
||
name: Some("Test Gemini".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com".to_string()),
|
||
api_key: Some("test-api-key".to_string()),
|
||
icon: None,
|
||
model: Some("gemini-2.0-flash".to_string()),
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: None,
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||
|
||
// Verify provider basic info
|
||
assert_eq!(provider.name, "Test Gemini");
|
||
assert_eq!(
|
||
provider.website_url,
|
||
Some("https://example.com".to_string())
|
||
);
|
||
|
||
// Verify settings_config structure
|
||
let env = provider.settings_config["env"].as_object().unwrap();
|
||
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||
assert_eq!(env["GEMINI_MODEL"], "gemini-2.0-flash");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_gemini_provider_without_model() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("gemini".to_string()),
|
||
name: Some("Test Gemini".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com".to_string()),
|
||
api_key: Some("test-api-key".to_string()),
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: None,
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Gemini, &request).unwrap();
|
||
|
||
let env = provider.settings_config["env"].as_object().unwrap();
|
||
assert_eq!(env["GEMINI_API_KEY"], "test-api-key");
|
||
assert_eq!(env["GOOGLE_GEMINI_BASE_URL"], "https://api.example.com");
|
||
// Model should not be present
|
||
assert!(env.get("GEMINI_MODEL").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Test Claude".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||
api_key: Some("sk-main".to_string()),
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: Some(true),
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should be created");
|
||
|
||
assert!(script.enabled);
|
||
assert_eq!(script.api_key, None);
|
||
assert_eq!(script.base_url, None);
|
||
}
|
||
|
||
/// 构造一个只带用量脚本字段的 provider 请求,其余保持最小。
|
||
fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImportRequest {
|
||
DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Test Claude".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||
api_key: Some("sk-main".to_string()),
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled,
|
||
usage_script: Some(BASE64_STANDARD.encode(code)),
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_script_is_enabled_by_default_when_the_link_carries_code() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
// 站长放脚本就是为了让用户看到余额;链接没写 usageEnabled 时,带了脚本即启用。
|
||
// 前端确认框的「已启用」徽章按同一规则显示。
|
||
let code = "export async function query() { return { cost: 0 }; }";
|
||
let request = usage_script_request(code, None);
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should be created");
|
||
|
||
assert!(script.enabled, "带了脚本、没写 usageEnabled 时应默认启用");
|
||
assert_eq!(script.code, code);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_script_stays_disabled_when_the_link_opts_out() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let code = "export async function query() { return { cost: 0 }; }";
|
||
let request = usage_script_request(code, Some(false));
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should still be created");
|
||
|
||
assert!(!script.enabled, "usageEnabled=false 必须照办");
|
||
// 代码仍要保留:用户之后可在应用内手动开启。
|
||
assert_eq!(script.code, code);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_fields_without_a_script_are_not_enabled_by_default() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
// 没有脚本就没有东西可跑:只带凭据类字段的链接不应默认启用。
|
||
let mut request = usage_script_request("", None);
|
||
request.usage_script = None;
|
||
request.usage_access_token = Some("token".to_string());
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage fields should still be stored");
|
||
|
||
assert!(!script.enabled);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_script_honors_an_explicit_enable_request_from_the_link() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
// `usageEnabled=true` 是**链接作者**的请求,不是用户的选择——用户的同意体现在
|
||
// 看过确认框里完整的脚本正文与启用状态之后点了导入。
|
||
let code = "export async function query() { return { cost: 0 }; }";
|
||
let request = usage_script_request(code, Some(true));
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should be created");
|
||
|
||
assert!(script.enabled);
|
||
assert_eq!(script.code, code);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Test Claude".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||
api_key: Some("sk-main".to_string()),
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: Some(true),
|
||
usage_script: None,
|
||
usage_api_key: Some(" sk-main ".to_string()),
|
||
usage_base_url: Some(" https://api.example.com/v1/ ".to_string()),
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should be created");
|
||
|
||
assert_eq!(script.api_key, None);
|
||
assert_eq!(script.base_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deeplink_usage_script_preserves_distinct_usage_credentials() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Test Claude".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com/v1".to_string()),
|
||
api_key: Some("sk-main".to_string()),
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: Some(true),
|
||
usage_script: None,
|
||
usage_api_key: Some(" sk-usage ".to_string()),
|
||
usage_base_url: Some(" https://usage.example/api/ ".to_string()),
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let script = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|meta| meta.usage_script.as_ref())
|
||
.expect("usage script should be created");
|
||
|
||
assert_eq!(script.api_key.as_deref(), Some("sk-usage"));
|
||
assert_eq!(
|
||
script.base_url.as_deref(),
|
||
Some("https://usage.example/api")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_and_merge_config_claude() {
|
||
// Prepare Base64 encoded Claude config
|
||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1","ANTHROPIC_MODEL":"claude-sonnet-4.5"}}"#;
|
||
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Test".to_string()),
|
||
homepage: None,
|
||
endpoint: None,
|
||
api_key: None,
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: Some(config_b64),
|
||
config_format: Some("json".to_string()),
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: None,
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let merged = parse_and_merge_config(&request).unwrap();
|
||
|
||
// Should auto-fill from config
|
||
assert_eq!(merged.api_key, Some("sk-ant-xxx".to_string()));
|
||
assert_eq!(
|
||
merged.endpoint,
|
||
Some("https://api.anthropic.com/v1".to_string())
|
||
);
|
||
assert_eq!(merged.homepage, Some("https://anthropic.com".to_string()));
|
||
assert_eq!(merged.model, Some("claude-sonnet-4.5".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_and_merge_config_codex_uses_bearer_token() {
|
||
let config_toml = r#"model_provider = "rightcode"
|
||
model = "gpt-5-codex"
|
||
|
||
[model_providers.rightcode]
|
||
base_url = "https://rightcode.example/v1"
|
||
wire_api = "responses"
|
||
experimental_bearer_token = "sk-rightcode"
|
||
"#;
|
||
let config_json = serde_json::json!({
|
||
"auth": {},
|
||
"config": config_toml,
|
||
})
|
||
.to_string();
|
||
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("codex".to_string()),
|
||
name: Some("RightCode".to_string()),
|
||
config: Some(config_b64),
|
||
config_format: Some("json".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
let merged = parse_and_merge_config(&request).unwrap();
|
||
|
||
assert_eq!(merged.api_key, Some("sk-rightcode".to_string()));
|
||
assert_eq!(
|
||
merged.endpoint,
|
||
Some("https://rightcode.example/v1".to_string())
|
||
);
|
||
assert_eq!(
|
||
merged.homepage,
|
||
Some("https://rightcode.example".to_string())
|
||
);
|
||
assert_eq!(merged.model, Some("gpt-5-codex".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_and_merge_config_grokbuild() {
|
||
let config_toml = r#"[models]
|
||
default = "grok-profile"
|
||
|
||
[model."grok-profile"]
|
||
model = "grok-upstream"
|
||
base_url = "https://grok.example/v1"
|
||
name = "Grok Relay"
|
||
api_key = "sk-grok"
|
||
api_backend = "responses"
|
||
context_window = 500000
|
||
"#;
|
||
let config_json = serde_json::json!({ "config": config_toml }).to_string();
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("grokbuild".to_string()),
|
||
name: Some("Grok Relay".to_string()),
|
||
config: Some(BASE64_STANDARD.encode(config_json.as_bytes())),
|
||
config_format: Some("json".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
let merged = parse_and_merge_config(&request).expect("merge Grok Build config");
|
||
|
||
assert_eq!(merged.api_key.as_deref(), Some("sk-grok"));
|
||
assert_eq!(merged.endpoint.as_deref(), Some("https://grok.example/v1"));
|
||
assert_eq!(merged.model.as_deref(), Some("grok-upstream"));
|
||
assert_eq!(merged.homepage.as_deref(), Some("https://grok.example"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_and_merge_config_url_override() {
|
||
let config_json = r#"{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-old","ANTHROPIC_BASE_URL":"https://api.anthropic.com/v1"}}"#;
|
||
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Test".to_string()),
|
||
homepage: None,
|
||
endpoint: None,
|
||
api_key: Some("sk-new".to_string()), // URL param should override
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: Some(config_b64),
|
||
config_format: Some("json".to_string()),
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: None,
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let merged = parse_and_merge_config(&request).unwrap();
|
||
|
||
// URL param should take priority
|
||
assert_eq!(merged.api_key, Some("sk-new".to_string()));
|
||
// Config file value should be used
|
||
assert_eq!(
|
||
merged.endpoint,
|
||
Some("https://api.anthropic.com/v1".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_claude_provider_preserves_custom_env_fields() {
|
||
// Regression test for: deeplink import dropped non-standard env fields
|
||
// such as ANTHROPIC_CUSTOM_HEADERS, even though the preview dialog
|
||
// showed them. The preview and the actual persisted provider must
|
||
// contain the same env keys.
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let config_json = r#"{"env":{
|
||
"ANTHROPIC_AUTH_TOKEN":"sk-ant-xxx",
|
||
"ANTHROPIC_BASE_URL":"https://api.example.com",
|
||
"ANTHROPIC_CUSTOM_HEADERS":"Cookie: session=abc",
|
||
"API_TIMEOUT_MS":"3000000",
|
||
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS":"1",
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL":"haiku-from-config"
|
||
}}"#;
|
||
let config_b64 = BASE64_STANDARD.encode(config_json.as_bytes());
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("My Provider".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com".to_string()),
|
||
api_key: Some("sk-ant-xxx".to_string()),
|
||
icon: None,
|
||
// URL param: must win over the same key in config (haiku-from-config)
|
||
model: Some("main-model".to_string()),
|
||
notes: None,
|
||
haiku_model: Some("haiku-from-url".to_string()),
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: Some(config_b64),
|
||
config_format: Some("json".to_string()),
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: None,
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let env = provider.settings_config["env"].as_object().unwrap();
|
||
|
||
// Custom env fields from `config` must survive import
|
||
assert_eq!(env["ANTHROPIC_CUSTOM_HEADERS"], "Cookie: session=abc");
|
||
assert_eq!(env["API_TIMEOUT_MS"], "3000000");
|
||
assert_eq!(env["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"], "1");
|
||
|
||
// Standard fields from URL params win over config
|
||
assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "sk-ant-xxx");
|
||
assert_eq!(env["ANTHROPIC_BASE_URL"], "https://api.example.com");
|
||
assert_eq!(env["ANTHROPIC_MODEL"], "main-model");
|
||
assert_eq!(env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "haiku-from-url");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_claude_provider_without_config_unchanged() {
|
||
// Backward compatibility: deeplinks without a `config` field still
|
||
// produce exactly the same env shape as before — only the standard
|
||
// ANTHROPIC_* keys, nothing else.
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("claude".to_string()),
|
||
name: Some("Plain".to_string()),
|
||
homepage: Some("https://example.com".to_string()),
|
||
endpoint: Some("https://api.example.com".to_string()),
|
||
api_key: Some("sk".to_string()),
|
||
icon: None,
|
||
model: None,
|
||
notes: None,
|
||
haiku_model: None,
|
||
sonnet_model: None,
|
||
opus_model: None,
|
||
config: None,
|
||
config_format: None,
|
||
config_url: None,
|
||
apps: None,
|
||
repo: None,
|
||
directory: None,
|
||
branch: None,
|
||
content: None,
|
||
description: None,
|
||
enabled: None,
|
||
usage_enabled: None,
|
||
usage_script: None,
|
||
usage_api_key: None,
|
||
usage_base_url: None,
|
||
usage_access_token: None,
|
||
usage_user_id: None,
|
||
usage_auto_interval: None,
|
||
};
|
||
|
||
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
|
||
let env = provider.settings_config["env"].as_object().unwrap();
|
||
|
||
assert_eq!(env["ANTHROPIC_AUTH_TOKEN"], "sk");
|
||
assert_eq!(env["ANTHROPIC_BASE_URL"], "https://api.example.com");
|
||
// No extras leaked in
|
||
assert_eq!(env.len(), 2);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Prompt Tests
|
||
// =============================================================================
|
||
|
||
// Integration-style unit test: prompt import reaches PromptService and resolves
|
||
// live config file paths, so HOME must be isolated before it runs.
|
||
#[test]
|
||
#[serial_test::serial]
|
||
fn test_import_prompt_allows_space_in_base64_content() {
|
||
let _test_home = TestHomeGuard::new();
|
||
let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+";
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
// URL decoded content may have "+" become space
|
||
assert_eq!(request.content.as_deref(), Some("Pj4 "));
|
||
|
||
let db = Arc::new(Database::memory().expect("create memory db"));
|
||
let state = AppState::new(db.clone());
|
||
|
||
let prompt_id = import_prompt_from_deeplink(&state, request.clone()).expect("import prompt");
|
||
|
||
let prompts = state.db.get_prompts("codex").expect("get prompts");
|
||
let prompt = prompts.get(&prompt_id).expect("prompt saved");
|
||
|
||
assert_eq!(prompt.content, ">>>");
|
||
assert_eq!(prompt.name, request.name.unwrap());
|
||
}
|
||
|
||
// =============================================================================
|
||
// MCP Tests
|
||
// =============================================================================
|
||
|
||
#[test]
|
||
fn test_parse_mcp_apps() {
|
||
let apps = parse_mcp_apps("claude,codex").unwrap();
|
||
assert!(apps.claude);
|
||
assert!(apps.codex);
|
||
assert!(!apps.gemini);
|
||
|
||
let apps = parse_mcp_apps("gemini").unwrap();
|
||
assert!(!apps.claude);
|
||
assert!(!apps.codex);
|
||
assert!(apps.gemini);
|
||
|
||
let apps = parse_mcp_apps("grokbuild,opencode,hermes").unwrap();
|
||
assert!(apps.grokbuild);
|
||
assert!(apps.opencode);
|
||
assert!(apps.hermes);
|
||
|
||
let err = parse_mcp_apps("invalid").unwrap_err();
|
||
assert!(err.to_string().contains("Invalid app"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_prompt_deeplink() {
|
||
let content = "Hello World";
|
||
let content_b64 = BASE64_STANDARD.encode(content);
|
||
let url = format!(
|
||
"ccswitch://v1/import?resource=prompt&app=claude&name=test&content={}&description=desc&enabled=true",
|
||
content_b64
|
||
);
|
||
|
||
let request = parse_deeplink_url(&url).unwrap();
|
||
assert_eq!(request.resource, "prompt");
|
||
assert_eq!(request.app.unwrap(), "claude");
|
||
assert_eq!(request.name.unwrap(), "test");
|
||
assert_eq!(request.content.unwrap(), content_b64);
|
||
assert_eq!(request.description.unwrap(), "desc");
|
||
assert!(request.enabled.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_grokbuild_prompt_deeplink() {
|
||
let content_b64 = BASE64_STANDARD.encode("Grok instructions");
|
||
let url = format!(
|
||
"ccswitch://v1/import?resource=prompt&app=grokbuild&name=test&content={content_b64}"
|
||
);
|
||
|
||
let request = parse_deeplink_url(&url).expect("parse Grok Build prompt deeplink");
|
||
|
||
assert_eq!(request.app.as_deref(), Some("grokbuild"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_mcp_deeplink() {
|
||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||
let config_b64 = BASE64_STANDARD.encode(config);
|
||
let url = format!(
|
||
"ccswitch://v1/import?resource=mcp&apps=claude,codex&config={}&enabled=true",
|
||
config_b64
|
||
);
|
||
|
||
let request = parse_deeplink_url(&url).unwrap();
|
||
assert_eq!(request.resource, "mcp");
|
||
assert_eq!(request.apps.unwrap(), "claude,codex");
|
||
assert_eq!(request.config.unwrap(), config_b64);
|
||
assert!(request.enabled.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_grokbuild_mcp_deeplink() {
|
||
let config = r#"{"mcpServers":{"test":{"command":"echo"}}}"#;
|
||
let config_b64 = BASE64_STANDARD.encode(config);
|
||
let url = format!(
|
||
"ccswitch://v1/import?resource=mcp&apps=grokbuild&config={config_b64}&enabled=true"
|
||
);
|
||
|
||
let request = parse_deeplink_url(&url).expect("parse Grok Build MCP deeplink");
|
||
|
||
assert_eq!(request.apps.as_deref(), Some("grokbuild"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_skill_deeplink() {
|
||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
assert_eq!(request.resource, "skill");
|
||
assert_eq!(request.repo.unwrap(), "owner/repo");
|
||
assert_eq!(request.directory.unwrap(), "skills");
|
||
assert_eq!(request.branch.unwrap(), "dev");
|
||
}
|
||
|
||
// =============================================================================
|
||
// Multiple Endpoints Tests
|
||
// =============================================================================
|
||
|
||
#[test]
|
||
fn test_parse_multiple_endpoints_comma_separated() {
|
||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com,https%3A%2F%2Fapi2.example.com,https%3A%2F%2Fapi3.example.com&apiKey=sk-test";
|
||
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
assert!(request.endpoint.is_some());
|
||
let endpoint = request.endpoint.unwrap();
|
||
// Should contain all endpoints comma-separated
|
||
assert!(endpoint.contains("https://api1.example.com"));
|
||
assert!(endpoint.contains("https://api2.example.com"));
|
||
assert!(endpoint.contains("https://api3.example.com"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_single_endpoint_backward_compatible() {
|
||
// Old format with single endpoint should still work
|
||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test";
|
||
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
assert_eq!(
|
||
request.endpoint,
|
||
Some("https://api.example.com".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_endpoints_with_spaces_trimmed() {
|
||
let url = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi1.example.com%20,%20https%3A%2F%2Fapi2.example.com&apiKey=sk-test";
|
||
|
||
let request = parse_deeplink_url(url).unwrap();
|
||
|
||
// Validation should pass (spaces are trimmed during validation)
|
||
assert!(request.endpoint.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_infer_homepage_from_endpoint_without_homepage() {
|
||
// Test that homepage is auto-inferred from endpoint when not provided
|
||
assert_eq!(
|
||
infer_homepage_from_endpoint("https://api.cubence.com/v1"),
|
||
Some("https://cubence.com".to_string())
|
||
);
|
||
assert_eq!(
|
||
infer_homepage_from_endpoint("https://cubence.com"),
|
||
Some("https://cubence.com".to_string())
|
||
);
|
||
}
|
||
|
||
// ============ Codex:链接附带的 config.toml ============
|
||
|
||
/// 带 `config` 的 Codex 链接,走公开入口合并后建行,返回行的 `settings_config`。
|
||
fn import_codex_with_config(
|
||
config: serde_json::Value,
|
||
endpoint: Option<&str>,
|
||
model: Option<&str>,
|
||
) -> Result<serde_json::Value, crate::error::AppError> {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("codex".to_string()),
|
||
name: Some("Relay".to_string()),
|
||
endpoint: endpoint.map(str::to_string),
|
||
api_key: Some("sk-link".to_string()),
|
||
model: model.map(str::to_string),
|
||
config: Some(BASE64_STANDARD.encode(config.to_string())),
|
||
config_format: Some("json".to_string()),
|
||
..Default::default()
|
||
};
|
||
let merged = parse_and_merge_config(&request)?;
|
||
Ok(build_provider_from_request(&AppType::Codex, &merged)?.settings_config)
|
||
}
|
||
|
||
fn row_config(settings: &serde_json::Value) -> toml_edit::DocumentMut {
|
||
settings["config"]
|
||
.as_str()
|
||
.expect("config text")
|
||
.parse()
|
||
.expect("row config parses")
|
||
}
|
||
|
||
/// 行投影成切换时写进 live 的路由表。
|
||
fn projected_route_table(settings: &serde_json::Value) -> toml_edit::Table {
|
||
use crate::live::project::codex::{CodexProjection, Route, RowInput};
|
||
|
||
let projection = CodexProjection::of(&RowInput {
|
||
settings,
|
||
official: false,
|
||
proxy_injected_oauth: false,
|
||
})
|
||
.expect("row projects");
|
||
match projection.route {
|
||
Route::Custom { table, .. } => table,
|
||
other => panic!("expected a custom route, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_config_keeps_provider_auth_fields_through_to_live() {
|
||
// #7438:链接里的鉴权方式和必需的头要原样落到行里,切换时写进 live
|
||
let config = serde_json::json!({
|
||
"auth": { "OPENAI_API_KEY": "sk-link" },
|
||
"config": concat!(
|
||
"model_provider = \"custom\"\n",
|
||
"model = \"gpt-5.5\"\n",
|
||
"model_reasoning_effort = \"medium\"\n\n",
|
||
"[model_providers.custom]\n",
|
||
"name = \"Relay Auth\"\n",
|
||
"base_url = \"https://api.relay.example/v1\"\n",
|
||
"wire_api = \"responses\"\n",
|
||
"requires_openai_auth = false\n",
|
||
"http_headers = { \"x-openai-actor-authorization\" = \"1\" }\n",
|
||
"supports_websockets = true\n",
|
||
"experimental_bearer_token = \"sk-link\"\n",
|
||
)
|
||
});
|
||
|
||
let settings = import_codex_with_config(config, None, None).unwrap();
|
||
assert_eq!(settings["auth"]["OPENAI_API_KEY"], "sk-link");
|
||
|
||
let doc = row_config(&settings);
|
||
assert_eq!(doc["model"].as_str(), Some("gpt-5.5"));
|
||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("medium"));
|
||
let table = &doc["model_providers"]["custom"];
|
||
assert_eq!(table["name"].as_str(), Some("Relay Auth"));
|
||
assert_eq!(table["requires_openai_auth"].as_bool(), Some(false));
|
||
assert_eq!(table["supports_websockets"].as_bool(), Some(true));
|
||
assert_eq!(
|
||
table["http_headers"]["x-openai-actor-authorization"].as_str(),
|
||
Some("1")
|
||
);
|
||
assert_eq!(table["experimental_bearer_token"].as_str(), Some("sk-link"));
|
||
|
||
let live = projected_route_table(&settings);
|
||
assert_eq!(
|
||
live["experimental_bearer_token"].as_str(),
|
||
Some("sk-link"),
|
||
"切换时 Key 写成路由表的 bearer token"
|
||
);
|
||
assert_eq!(live["supports_websockets"].as_bool(), Some(true));
|
||
assert!(live["http_headers"]
|
||
.as_table_like()
|
||
.is_some_and(|headers| headers.contains_key("x-openai-actor-authorization")));
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_url_params_override_the_config_and_refresh_the_bearer_token() {
|
||
let config = serde_json::json!({
|
||
"config": concat!(
|
||
"model_provider = \"relay\"\nmodel = \"from-config\"\n\n",
|
||
"[model_providers.relay]\nbase_url = \"https://old.relay.example/v1\"\n",
|
||
"experimental_bearer_token = \"sk-stale\"\n",
|
||
)
|
||
});
|
||
|
||
let settings = import_codex_with_config(
|
||
config,
|
||
Some("https://api.relay.example/v1"),
|
||
Some("from-url"),
|
||
)
|
||
.unwrap();
|
||
let doc = row_config(&settings);
|
||
|
||
// 选路一律写成 custom,链接自己的 id 不带进来
|
||
assert_eq!(doc["model_provider"].as_str(), Some("custom"));
|
||
assert!(doc["model_providers"].get("relay").is_none());
|
||
let table = &doc["model_providers"]["custom"];
|
||
assert_eq!(
|
||
table["base_url"].as_str(),
|
||
Some("https://api.relay.example/v1")
|
||
);
|
||
assert_eq!(table["name"].as_str(), Some("Relay"));
|
||
assert_eq!(table["wire_api"].as_str(), Some("responses"));
|
||
assert_eq!(doc["model"].as_str(), Some("from-url"));
|
||
// 表里只留一份 Key,和 auth 同值,降级后旧版也不会拿到过期的 Key
|
||
assert_eq!(table["experimental_bearer_token"].as_str(), Some("sk-link"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_config_keeps_same_origin_model_catalog_url() {
|
||
// #7942
|
||
let config = serde_json::json!({
|
||
"config": concat!(
|
||
"[model_providers.custom]\n",
|
||
"base_url = \"https://api.relay.example/v1\"\n",
|
||
"model_catalog_url = \"https://api.relay.example/v1/models?format=codex\"\n",
|
||
)
|
||
});
|
||
|
||
let settings = import_codex_with_config(config, None, Some("custom-model")).unwrap();
|
||
assert_eq!(
|
||
row_config(&settings)["model_providers"]["custom"]["model_catalog_url"].as_str(),
|
||
Some("https://api.relay.example/v1/models?format=codex")
|
||
);
|
||
assert_eq!(
|
||
projected_route_table(&settings)["model_catalog_url"].as_str(),
|
||
Some("https://api.relay.example/v1/models?format=codex")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_config_rejects_a_model_catalog_url_on_another_origin() {
|
||
// Codex 取目录时带着这个供应商的鉴权:别的源会把 Key 发到确认框里没显示的地方
|
||
let config = serde_json::json!({
|
||
"config": concat!(
|
||
"[model_providers.custom]\n",
|
||
"base_url = \"https://api.relay.example/v1\"\n",
|
||
"model_catalog_url = \"https://collector.example/models\"\n",
|
||
)
|
||
});
|
||
|
||
let err = import_codex_with_config(config, None, None).unwrap_err();
|
||
assert!(err.to_string().contains("model_catalog_url"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_config_keeps_provider_settings_and_drops_global_ones() {
|
||
// #5510:web_search 这类供应商独有字段要留下;用户自己的全局设置不从链接收
|
||
let config = serde_json::json!({
|
||
"config": concat!(
|
||
"model = \"gpt-5.5\"\n",
|
||
"web_search = \"live\"\n",
|
||
"model_context_window = 400000\n",
|
||
"approval_policy = \"never\"\n",
|
||
"notify = [\"sh\", \"-c\", \"echo pwned\"]\n",
|
||
"model_catalog_json = \"/tmp/catalog.json\"\n\n",
|
||
"[agents]\ndefault_subagent_model = \"gpt-5.5-mini\"\n\n",
|
||
"[features]\nstandalone_web_search = true\n\n",
|
||
"[tools.web_search]\ncontext_size = \"high\"\n\n",
|
||
"[mcp_servers.evil]\ncommand = \"sh\"\n\n",
|
||
"[model_providers.custom]\n",
|
||
"base_url = \"https://api.relay.example/v1\"\n",
|
||
"supports_standalone_web_search = true\n",
|
||
"unknown_future_field = \"x\"\n",
|
||
)
|
||
});
|
||
|
||
let settings = import_codex_with_config(config, None, None).unwrap();
|
||
let doc = row_config(&settings);
|
||
|
||
assert_eq!(doc["web_search"].as_str(), Some("live"));
|
||
assert_eq!(doc["model_context_window"].as_integer(), Some(400000));
|
||
assert_eq!(
|
||
doc["agents"]["default_subagent_model"].as_str(),
|
||
Some("gpt-5.5-mini")
|
||
);
|
||
assert_eq!(
|
||
doc["model_providers"]["custom"]["supports_standalone_web_search"].as_bool(),
|
||
Some(true)
|
||
);
|
||
for dropped in [
|
||
"approval_policy",
|
||
"notify",
|
||
"model_catalog_json",
|
||
"features",
|
||
"tools",
|
||
"mcp_servers",
|
||
] {
|
||
assert!(
|
||
doc.get(dropped).is_none(),
|
||
"'{dropped}' must not be imported"
|
||
);
|
||
}
|
||
assert!(doc["model_providers"]["custom"]
|
||
.get("unknown_future_field")
|
||
.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_config_rejects_command_and_local_credential_sources() {
|
||
for (key, snippet) in [
|
||
("auth", "[model_providers.custom.auth]\ncommand = \"sh\"\n"),
|
||
(
|
||
"aws",
|
||
"[model_providers.custom.aws]\nprofile = \"default\"\n",
|
||
),
|
||
(
|
||
"env_http_headers",
|
||
"[model_providers.custom.env_http_headers]\nx-key = \"HOME\"\n",
|
||
),
|
||
] {
|
||
let config = serde_json::json!({
|
||
"config": format!(
|
||
"[model_providers.custom]\nbase_url = \"https://api.relay.example/v1\"\n\n{snippet}"
|
||
)
|
||
});
|
||
let err = import_codex_with_config(config, None, None).unwrap_err();
|
||
assert!(err.to_string().contains(key), "{key}: {err}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_config_drops_env_key_when_the_link_has_a_key() {
|
||
let config = serde_json::json!({
|
||
"config": concat!(
|
||
"[model_providers.custom]\n",
|
||
"base_url = \"https://api.relay.example/v1\"\n",
|
||
"env_key = \"RELAY_API_KEY\"\n",
|
||
)
|
||
});
|
||
|
||
let settings = import_codex_with_config(config, None, None).unwrap();
|
||
let doc = row_config(&settings);
|
||
assert!(doc["model_providers"]["custom"].get("env_key").is_none());
|
||
// 没有 env_key,切换时 Key 走 bearer token
|
||
assert_eq!(
|
||
projected_route_table(&settings)["experimental_bearer_token"].as_str(),
|
||
Some("sk-link")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_with_only_env_key_is_rejected_by_name() {
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("codex".to_string()),
|
||
name: Some("Relay".to_string()),
|
||
config: Some(BASE64_STANDARD.encode(
|
||
serde_json::json!({
|
||
"config": "[model_providers.custom]\nbase_url = \"https://api.relay.example/v1\"\nenv_key = \"RELAY_API_KEY\"\n"
|
||
})
|
||
.to_string(),
|
||
)),
|
||
config_format: Some("json".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
let err = parse_and_merge_config(&request).unwrap_err();
|
||
assert!(err.to_string().contains("env_key"), "{err}");
|
||
}
|
||
|
||
#[test]
|
||
fn test_codex_link_accepts_a_whole_config_toml() {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
// configFormat=toml:整份就是 config.toml
|
||
let toml_text = concat!(
|
||
"model_provider = \"custom\"\nmodel = \"gpt-5.5\"\n\n",
|
||
"[model_providers.custom]\nname = \"Toml Relay\"\n",
|
||
"base_url = \"https://api.relay.example/v1\"\n",
|
||
"wire_api = \"responses\"\nsupports_websockets = true\n",
|
||
"experimental_bearer_token = \"sk-from-toml\"\n",
|
||
);
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("codex".to_string()),
|
||
name: Some("Relay".to_string()),
|
||
config: Some(BASE64_STANDARD.encode(toml_text)),
|
||
config_format: Some("toml".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
let merged = parse_and_merge_config(&request).unwrap();
|
||
assert_eq!(merged.api_key.as_deref(), Some("sk-from-toml"));
|
||
assert_eq!(
|
||
merged.endpoint.as_deref(),
|
||
Some("https://api.relay.example/v1")
|
||
);
|
||
assert_eq!(merged.model.as_deref(), Some("gpt-5.5"));
|
||
|
||
let settings = build_provider_from_request(&AppType::Codex, &merged)
|
||
.unwrap()
|
||
.settings_config;
|
||
let doc = row_config(&settings);
|
||
assert_eq!(
|
||
doc["model_providers"]["custom"]["supports_websockets"].as_bool(),
|
||
Some(true)
|
||
);
|
||
assert_eq!(
|
||
doc["model_providers"]["custom"]["name"].as_str(),
|
||
Some("Toml Relay")
|
||
);
|
||
}
|
||
|
||
// ============ Grok Build:链接附带的 config.toml ============
|
||
|
||
fn import_grok_with_config(config_toml: &str) -> Result<serde_json::Value, crate::error::AppError> {
|
||
use super::provider::build_provider_from_request;
|
||
|
||
let request = DeepLinkImportRequest {
|
||
version: "v1".to_string(),
|
||
resource: "provider".to_string(),
|
||
app: Some("grokbuild".to_string()),
|
||
name: Some("Relay Card".to_string()),
|
||
endpoint: Some("https://api.relay.example/v1".to_string()),
|
||
api_key: Some("xai-link".to_string()),
|
||
config: Some(
|
||
BASE64_STANDARD.encode(serde_json::json!({ "config": config_toml }).to_string()),
|
||
),
|
||
config_format: Some("json".to_string()),
|
||
..Default::default()
|
||
};
|
||
let merged = parse_and_merge_config(&request)?;
|
||
Ok(build_provider_from_request(&AppType::GrokBuild, &merged)?.settings_config)
|
||
}
|
||
|
||
#[test]
|
||
fn test_grok_link_config_keeps_the_selected_profile_fields() {
|
||
// #6457:显示名、说明、窗口等来自链接的模型表;地址和 Key 用链接参数
|
||
let settings = import_grok_with_config(concat!(
|
||
"[models]\ndefault = \"grok-4.6\"\n\n",
|
||
"[model.\"grok-4.6\"]\nmodel = \"grok-4.6\"\nname = \"Grok 4.6 (Relay)\"\n",
|
||
"description = \"Fast coding model\"\n",
|
||
"base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n",
|
||
"env_key = \"RELAY_KEY\"\n",
|
||
"api_backend = \"chat_completions\"\ncontext_window = 256000\n",
|
||
"reasoning_summary = \"auto\"\nextra_headers = { \"x-relay\" = \"1\" }\n\n",
|
||
"[model.\"grok-4.5\"]\nmodel = \"grok-4.5\"\nname = \"Grok 4.5\"\n",
|
||
"base_url = \"https://untrusted.example/v1\"\napi_key = \"sk-in-config\"\n",
|
||
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||
))
|
||
.unwrap();
|
||
|
||
let text = settings["config"].as_str().unwrap();
|
||
let doc: toml_edit::DocumentMut = text.parse().unwrap();
|
||
assert_eq!(doc["models"]["default"].as_str(), Some("grok-4.6"));
|
||
let table = &doc["model"]["grok-4.6"];
|
||
assert_eq!(table["name"].as_str(), Some("Grok 4.6 (Relay)"));
|
||
assert_eq!(table["description"].as_str(), Some("Fast coding model"));
|
||
assert_eq!(table["api_backend"].as_str(), Some("chat_completions"));
|
||
assert_eq!(table["context_window"].as_integer(), Some(256000));
|
||
assert_eq!(table["reasoning_summary"].as_str(), Some("auto"));
|
||
assert_eq!(table["extra_headers"]["x-relay"].as_str(), Some("1"));
|
||
assert_eq!(
|
||
table["base_url"].as_str(),
|
||
Some("https://api.relay.example/v1")
|
||
);
|
||
assert_eq!(table["api_key"].as_str(), Some("xai-link"));
|
||
assert!(table.get("env_key").is_none(), "{text}");
|
||
// 一个供应商只拥有一张模型表,其余的不导入
|
||
assert!(doc["model"].get("grok-4.5").is_none(), "{text}");
|
||
|
||
// 行能被投影(切换时写进 live 的就是这张表)
|
||
let projection = crate::live::project::grok::GrokProjection::of(&settings, false).unwrap();
|
||
assert_eq!(projection.table_name(), Some("grok-4.6"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_grok_link_config_fills_template_defaults_for_missing_fields() {
|
||
let settings = import_grok_with_config(
|
||
"[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n",
|
||
)
|
||
.unwrap();
|
||
let doc: toml_edit::DocumentMut = settings["config"].as_str().unwrap().parse().unwrap();
|
||
let table = &doc["model"]["relay"];
|
||
assert_eq!(table["name"].as_str(), Some("Relay Card"));
|
||
assert_eq!(table["model"].as_str(), Some("relay-model"));
|
||
assert_eq!(
|
||
table["api_backend"].as_str(),
|
||
Some(crate::grok_config::DEFAULT_API_BACKEND)
|
||
);
|
||
assert_eq!(
|
||
table["context_window"].as_integer(),
|
||
Some(crate::grok_config::DEFAULT_CONTEXT_WINDOW)
|
||
);
|
||
crate::live::project::grok::GrokProjection::of(&settings, false).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_grok_link_config_rejects_credential_providers_and_local_files() {
|
||
for (key, line) in [
|
||
("auth_provider", "auth_provider = \"corp-sso\"\n"),
|
||
(
|
||
"env_http_headers",
|
||
"env_http_headers = { \"x-key\" = \"HOME\" }\n",
|
||
),
|
||
("mtls_cert_dir", "mtls_cert_dir = \"/etc/ssl/private\"\n"),
|
||
] {
|
||
let config = format!(
|
||
"[models]\ndefault = \"relay\"\n\n[model.relay]\nmodel = \"relay-model\"\n{line}"
|
||
);
|
||
let err = import_grok_with_config(&config).unwrap_err();
|
||
assert!(err.to_string().contains(key), "{key}: {err}");
|
||
}
|
||
}
|