diff --git a/docs/user-manual/en/5-faq/5.3-deeplink.md b/docs/user-manual/en/5-faq/5.3-deeplink.md index d42b981af3..c581a6d0e1 100644 --- a/docs/user-manual/en/5-faq/5.3-deeplink.md +++ b/docs/user-manual/en/5-faq/5.3-deeplink.md @@ -53,6 +53,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | `haikuModel` | No | Haiku model (Claude only) | | `sonnetModel` | No | Sonnet model (Claude only) | | `opusModel` | No | Opus model (Claude only) | +| `npm` | No | npm package for OpenCode provider (default `@ai-sdk/openai-compatible`; e.g. `@ai-sdk/google` for Gemini-native routing) | | `notes` | No | Notes | | `icon` | No | Icon | | `config` | No | Base64-encoded configuration content | diff --git a/docs/user-manual/ja/5-faq/5.3-deeplink.md b/docs/user-manual/ja/5-faq/5.3-deeplink.md index e2f643ebcd..c73296c4ed 100644 --- a/docs/user-manual/ja/5-faq/5.3-deeplink.md +++ b/docs/user-manual/ja/5-faq/5.3-deeplink.md @@ -53,6 +53,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | `haikuModel` | いいえ | Haiku モデル(Claude のみ) | | `sonnetModel` | いいえ | Sonnet モデル(Claude のみ) | | `opusModel` | いいえ | Opus モデル(Claude のみ) | +| `npm` | いいえ | OpenCode プロバイダー用 npm パッケージ(デフォルト `@ai-sdk/openai-compatible`。例: Gemini ネイティブの場合は `@ai-sdk/google`) | | `notes` | いいえ | メモ | | `icon` | いいえ | アイコン | | `config` | いいえ | Base64 エンコードされた設定内容 | diff --git a/docs/user-manual/zh/5-faq/5.3-deeplink.md b/docs/user-manual/zh/5-faq/5.3-deeplink.md index 82ddebb6c6..945b1d2e48 100644 --- a/docs/user-manual/zh/5-faq/5.3-deeplink.md +++ b/docs/user-manual/zh/5-faq/5.3-deeplink.md @@ -53,6 +53,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&... | `haikuModel` | 否 | Haiku 模型(仅 Claude) | | `sonnetModel` | 否 | Sonnet 模型(仅 Claude) | | `opusModel` | 否 | Opus 模型(仅 Claude) | +| `npm` | 否 | OpenCode 提供商的 npm 包(默认 `@ai-sdk/openai-compatible`;例如 `@ai-sdk/google` 走 Gemini 原生路径) | | `notes` | 否 | 备注 | | `icon` | 否 | 图标 | | `config` | 否 | Base64 编码的配置内容 | diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 4bd3c80361..72a5d038f2 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -78,6 +78,11 @@ pub struct DeepLinkImportRequest { /// Optional Opus model (Claude only, v3.7.1+) #[serde(skip_serializing_if = "Option::is_none")] pub opus_model: Option, + /// Optional npm package override (OpenCode only). + /// Defaults to "@ai-sdk/openai-compatible" when omitted. Use this to point + /// OpenCode at a provider-specific SDK (e.g. "@ai-sdk/google" for Gemini). + #[serde(skip_serializing_if = "Option::is_none")] + pub npm: Option, // ============ Prompt-specific fields ============ /// Base64 encoded Markdown content diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index 290e30318c..01ea0c62c7 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -120,6 +120,7 @@ fn parse_provider_deeplink( let haiku_model = params.get("haikuModel").cloned(); let sonnet_model = params.get("sonnetModel").cloned(); let opus_model = params.get("opusModel").cloned(); + let npm = params.get("npm").cloned(); let icon = params .get("icon") .map(|v| v.trim().to_lowercase()) @@ -157,6 +158,7 @@ fn parse_provider_deeplink( haiku_model, sonnet_model, opus_model, + npm, content: None, description: None, apps: None, @@ -229,6 +231,7 @@ fn parse_prompt_deeplink( haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, apps: None, repo: None, directory: None, @@ -295,6 +298,7 @@ fn parse_mcp_deeplink( haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, content: None, description: None, repo: None, @@ -350,6 +354,7 @@ fn parse_skill_deeplink( haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, content: None, description: None, apps: None, diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 87e07282f4..548a820faf 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -385,9 +385,16 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value models.insert(model.clone(), json!({ "name": model })); } - // Default to openai-compatible npm package + // Resolve npm package: explicit override (e.g. "@ai-sdk/google" for Gemini- + // native routing) wins; fall back to the generic OpenAI-compatible SDK. + let npm = request + .npm + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("@ai-sdk/openai-compatible"); + json!({ - "npm": "@ai-sdk/openai-compatible", + "npm": npm, "options": options, "models": models }) @@ -689,6 +696,14 @@ fn merge_additive_config( } } + // Extract npm package override from config if not provided in URL + // (OpenCode only; openclaw ignores npm at build time) + if request.npm.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(npm) = config.get("npm").and_then(|v| v.as_str()) { + request.npm = Some(npm.to_string()); + } + } + // Auto-fill homepage from endpoint if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index a56187f844..309d04f700 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -34,6 +34,16 @@ fn test_parse_valid_claude_deeplink() { assert_eq!(request.icon, Some("claude".to_string())); } +#[test] +fn test_parse_opencode_deeplink_with_npm_param() { + let url = "ccswitch://v1/import?resource=provider&app=opencode&name=Relay&homepage=https%3A%2F%2Frelay.example.com&endpoint=https%3A%2F%2Frelay.example.com%2Fv1beta&apiKey=sk-test&npm=%40ai-sdk%2Fgoogle"; + + let request = parse_deeplink_url(url).unwrap(); + + assert_eq!(request.app, Some("opencode".to_string())); + assert_eq!(request.npm, Some("@ai-sdk/google".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"; @@ -135,6 +145,7 @@ fn test_build_gemini_provider_with_model() { haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, config: None, config_format: None, config_url: None, @@ -188,6 +199,7 @@ fn test_build_gemini_provider_without_model() { haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, config: None, config_format: None, config_url: None, @@ -216,6 +228,98 @@ fn test_build_gemini_provider_without_model() { assert!(env.get("GEMINI_MODEL").is_none()); } +#[test] +fn test_build_opencode_provider_defaults_npm_when_absent() { + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("opencode".to_string()), + name: Some("Test OpenCode".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com/v1".to_string()), + api_key: Some("test-api-key".to_string()), + icon: None, + model: Some("gpt-5".to_string()), + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + npm: 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::OpenCode, &request).unwrap(); + assert_eq!(provider.settings_config["npm"], "@ai-sdk/openai-compatible"); +} + +#[test] +fn test_build_opencode_provider_honors_npm_override() { + use super::provider::build_provider_from_request; + + let request = DeepLinkImportRequest { + version: "v1".to_string(), + resource: "provider".to_string(), + app: Some("opencode".to_string()), + name: Some("Gemini via OpenCode".to_string()), + homepage: Some("https://example.com".to_string()), + endpoint: Some("https://api.example.com/v1beta".to_string()), + api_key: Some("test-api-key".to_string()), + icon: None, + model: Some("gemini-2.5-flash".to_string()), + notes: None, + haiku_model: None, + sonnet_model: None, + opus_model: None, + npm: Some("@ai-sdk/google".to_string()), + 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::OpenCode, &request).unwrap(); + assert_eq!(provider.settings_config["npm"], "@ai-sdk/google"); + assert_eq!( + provider.settings_config["options"]["baseURL"], + "https://api.example.com/v1beta" + ); + assert_eq!( + provider.settings_config["options"]["apiKey"], + "test-api-key" + ); +} + #[test] fn test_parse_and_merge_config_claude() { // Prepare Base64 encoded Claude config @@ -236,6 +340,7 @@ fn test_parse_and_merge_config_claude() { haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, config: Some(config_b64), config_format: Some("json".to_string()), config_url: None, @@ -286,6 +391,7 @@ fn test_parse_and_merge_config_url_override() { haiku_model: None, sonnet_model: None, opus_model: None, + npm: None, config: Some(config_b64), config_format: Some("json".to_string()), config_url: None,