Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/user-manual/en/5-faq/5.3-deeplink.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/user-manual/ja/5-faq/5.3-deeplink.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 エンコードされた設定内容 |
Expand Down
1 change: 1 addition & 0 deletions docs/user-manual/zh/5-faq/5.3-deeplink.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 编码的配置内容 |
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/deeplink/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,

// ============ Prompt-specific fields ============
/// Base64 encoded Markdown content
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/deeplink/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -157,6 +158,7 @@ fn parse_provider_deeplink(
haiku_model,
sonnet_model,
opus_model,
npm,
content: None,
description: None,
apps: None,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 17 additions & 2 deletions src-tauri/src/deeplink/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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()) {
Expand Down
106 changes: 106 additions & 0 deletions src-tauri/src/deeplink/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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&notes=Test%20notes";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading