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

@ -436,15 +436,28 @@ fn opencode_provider_config_json(
"model".to_owned(),
Value::String(format!("{}/{}", config.provider_id, config.model)),
);
let mut options = Map::new();
options.insert("apiKey".to_owned(), Value::String(api_key.to_owned()));
let mut provider_entry = 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(), Value::String(custom.base_url.clone()));
provider_entry.insert("npm".to_owned(), Value::String(custom.npm.clone()));
let model_label = custom
.display_name
.clone()
.unwrap_or_else(|| config.model.clone());
provider_entry.insert(
"models".to_owned(),
json!({ config.model.as_str(): { "name": model_label } }),
);
}
provider_entry.insert("options".to_owned(), Value::Object(options));
root.insert(
"provider".to_owned(),
json!({
config.provider_id.as_str(): {
"options": {
"apiKey": api_key
}
}
}),
json!({ config.provider_id.as_str(): provider_entry }),
);
let (command, args) = match runtime {
@ -594,3 +607,69 @@ fn parent_dir(base: &ProjectPath, rel: &str) -> String {
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
}
}
#[cfg(test)]
mod opencode_provider_config_json_tests {
use domain::profile::CustomProviderConfig;
use domain::ports::SecretRef;
use super::*;
fn known_provider_config() -> OpenCodeProviderConfig {
OpenCodeProviderConfig::new(
"anthropic",
"claude-sonnet-5",
SecretRef::new("secret-ref"),
)
.unwrap()
}
fn custom_provider_config() -> OpenCodeProviderConfig {
let custom =
CustomProviderConfig::new("@ai-sdk/openai-compatible", "https://my-endpoint/v1", None)
.unwrap();
OpenCodeProviderConfig::new("my-custom", "my-model", SecretRef::new("secret-ref"))
.unwrap()
.with_custom(custom)
}
#[test]
fn known_provider_emits_only_the_api_key_option() {
let config = known_provider_config();
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 custom_provider_emits_npm_base_url_and_models() {
let config = custom_provider_config();
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 custom_provider_model_name_prefers_display_name() {
let custom = CustomProviderConfig::new(
"@ai-sdk/openai-compatible",
"https://my-endpoint/v1",
Some("My Model".to_owned()),
)
.unwrap();
let config = OpenCodeProviderConfig::new("my-custom", "my-model", 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"
);
}
}