feat(backend): catalogue de providers OpenCode dynamique + provider personnalisé (#92)

Le catalogue de providers OpenCode lit désormais le cache local
~/.cache/opencode/models.json pour refléter les providers réellement
disponibles, avec repli garanti sur le catalogue statique en cas
d'absence ou d'erreur de lecture du cache. Ajout d'un champ additif
`custom` sur OpenCodeProviderConfig pour permettre à l'utilisateur de
déclarer un provider hors catalogue (id + clé API en saisie libre).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:43:59 +02:00
parent 162e3ae641
commit e943a0efed
10 changed files with 502 additions and 57 deletions

View File

@ -2761,15 +2761,37 @@ fn opencode_provider_config_json(
"model".to_owned(),
serde_json::Value::String(format!("{}/{}", config.provider_id, config.model)),
);
let mut options = serde_json::Map::new();
options.insert(
"apiKey".to_owned(),
serde_json::Value::String(api_key.to_owned()),
);
let mut provider_entry = serde_json::Map::new();
if let Some(custom) = config.custom.as_ref() {
// Provider outside the OpenCode registry: it needs the AI SDK package
// (`npm`), the endpoint (`options.baseURL`), and a `models` block —
// OpenCode has no built-in knowledge of this provider otherwise.
options.insert(
"baseURL".to_owned(),
serde_json::Value::String(custom.base_url.clone()),
);
provider_entry.insert(
"npm".to_owned(),
serde_json::Value::String(custom.npm.clone()),
);
let model_label = custom
.display_name
.clone()
.unwrap_or_else(|| config.model.clone());
provider_entry.insert(
"models".to_owned(),
serde_json::json!({ config.model.as_str(): { "name": model_label } }),
);
}
provider_entry.insert("options".to_owned(), serde_json::Value::Object(options));
root.insert(
"provider".to_owned(),
serde_json::json!({
config.provider_id.as_str(): {
"options": {
"apiKey": api_key
}
}
}),
serde_json::json!({ config.provider_id.as_str(): provider_entry }),
);
let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime);
@ -4379,4 +4401,67 @@ command = "idea-mcp"
assert!(merged.contains("user_key = \"keep-me\""));
assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\""));
}
#[test]
fn opencode_provider_config_json_known_provider_emits_only_the_api_key_option() {
let config = OpenCodeProviderConfig::new(
"anthropic",
"claude-sonnet-5",
domain::ports::SecretRef::new("secret-ref"),
)
.unwrap();
let body = opencode_provider_config_json(&config, "sk-live", "/project", None);
let provider = &body["provider"]["anthropic"];
assert_eq!(provider["options"]["apiKey"], "sk-live");
assert!(provider["options"].get("baseURL").is_none());
assert!(provider.get("npm").is_none());
assert!(provider.get("models").is_none());
}
#[test]
fn opencode_provider_config_json_custom_provider_emits_npm_base_url_and_models() {
let custom = domain::profile::CustomProviderConfig::new(
"@ai-sdk/openai-compatible",
"https://my-endpoint/v1",
None,
)
.unwrap();
let config = OpenCodeProviderConfig::new(
"my-custom",
"my-model",
domain::ports::SecretRef::new("secret-ref"),
)
.unwrap()
.with_custom(custom);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None);
let provider = &body["provider"]["my-custom"];
assert_eq!(provider["options"]["apiKey"], "sk-live");
assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
assert_eq!(provider["npm"], "@ai-sdk/openai-compatible");
assert_eq!(provider["models"]["my-model"]["name"], "my-model");
}
#[test]
fn opencode_provider_config_json_custom_provider_model_name_prefers_display_name() {
let custom = domain::profile::CustomProviderConfig::new(
"@ai-sdk/openai-compatible",
"https://my-endpoint/v1",
Some("My Model".to_owned()),
)
.unwrap();
let config = OpenCodeProviderConfig::new(
"my-custom",
"my-model",
domain::ports::SecretRef::new("ref"),
)
.unwrap()
.with_custom(custom);
let body = opencode_provider_config_json(&config, "sk-live", "/project", None);
assert_eq!(
body["provider"]["my-custom"]["models"]["my-model"]["name"],
"My Model"
);
}
}