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

@ -13,6 +13,11 @@ async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
subtle = { workspace = true }
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
# dynamic provider catalogue (ticket #92 follow-up). See
# `provider_catalogue::opencode_models_cache_path` for why `dirs::home_dir()`
# is used instead of `dirs::cache_dir()`.
dirs = { workspace = true }
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
uuid = { workspace = true }
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).

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"
);
}
}

View File

@ -1,59 +1,167 @@
//! Static catalogue of OpenCode **cloud** providers (ticket #92, lot B3).
//! Catalogue of OpenCode **cloud** providers (ticket #92, lots B3 and the
//! dynamic-catalogue follow-up).
//!
//! No OpenCode sub-command exposes a stable, machine-readable provider list
//! (cadrage Architect), so the catalogue is hard-coded data — same pattern as
//! [`super::catalogue::reference_profiles`]: a product decision about *which*
//! providers to offer, expressed as data, not code (Open/Closed).
//! (cadrage Architect), but OpenCode itself maintains a local cache of the
//! [models.dev](https://models.dev) registry it downloads on startup
//! (`<cache>/opencode/models.json`, format `{providerId: {name, models:
//! {modelId: {name, ...}}}}`). [`opencode_provider_catalogue`] reads that
//! cache when present and well-formed, so the picker reflects the *real*
//! OpenCode registry (hundreds of providers) instead of a hand-picked few.
//!
//! The cache is **not guaranteed** to exist or parse (OpenCode never run on
//! this machine, cache cleared, upstream format change, …), so a small
//! static fallback — the original three entries from lot B3 — is always
//! available and used whenever the cache can't be read. This fallback is
//! infallible by construction: [`ListOpenCodeProviders::execute`] never
//! errors.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Deserialize;
/// One entry of the OpenCode cloud-provider catalogue.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenCodeProviderCatalogEntry {
/// Identifier in the OpenCode provider registry (e.g. `"anthropic"`).
pub provider_id: &'static str,
pub provider_id: String,
/// Human-readable label for the picker UI.
pub display_name: &'static str,
/// Model names this provider serves, offered for selection.
pub models: &'static [&'static str],
pub display_name: String,
/// Model identifiers this provider serves, offered for selection.
pub models: Vec<String>,
}
/// Returns the static OpenCode cloud-provider catalogue.
#[must_use]
pub fn opencode_provider_catalogue() -> &'static [OpenCodeProviderCatalogEntry] {
&[
/// The original lot-B3 catalogue: three well-known providers, used whenever
/// the real OpenCode model cache can't be read or parsed.
fn static_fallback_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
vec![
OpenCodeProviderCatalogEntry {
provider_id: "anthropic",
display_name: "Anthropic",
models: &[
"claude-sonnet-5",
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
provider_id: "anthropic".to_owned(),
display_name: "Anthropic".to_owned(),
models: vec![
"claude-sonnet-5".to_owned(),
"claude-opus-4-8".to_owned(),
"claude-haiku-4-5-20251001".to_owned(),
],
},
OpenCodeProviderCatalogEntry {
provider_id: "openrouter",
display_name: "OpenRouter",
models: &[
"anthropic/claude-sonnet-5",
"openai/gpt-5",
"google/gemini-3-pro",
provider_id: "openrouter".to_owned(),
display_name: "OpenRouter".to_owned(),
models: vec![
"anthropic/claude-sonnet-5".to_owned(),
"openai/gpt-5".to_owned(),
"google/gemini-3-pro".to_owned(),
],
},
OpenCodeProviderCatalogEntry {
provider_id: "openai",
display_name: "OpenAI",
models: &["gpt-5", "gpt-5-mini"],
provider_id: "openai".to_owned(),
display_name: "OpenAI".to_owned(),
models: vec!["gpt-5".to_owned(), "gpt-5-mini".to_owned()],
},
]
}
/// Resolves the path OpenCode itself uses for its models.dev cache.
///
/// OpenCode has a documented upstream bug
/// (<https://github.com/sst/opencode/issues/8235>): it resolves its cache
/// directory via the **Linux XDG convention** (`$XDG_CACHE_HOME`, else
/// `~/.cache`) on every platform it runs on, including Windows, instead of
/// the platform-appropriate directory `dirs::cache_dir()` would return
/// (`%LOCALAPPDATA%` on Windows). To find the *same file OpenCode actually
/// wrote*, this function deliberately mimics that non-conforming behaviour —
/// it resolves from `dirs::home_dir()` plus the literal suffix
/// `.cache/opencode/models.json`, NOT `dirs::cache_dir()`. This is a
/// **permanent** compatibility shim tied to the upstream bug, not a
/// workaround for "OpenCode never launched": even on a machine where OpenCode
/// runs regularly, this is the only path that matches its actual output.
fn opencode_models_cache_path() -> Option<PathBuf> {
let cache_home = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
Some(cache_home.join("opencode").join("models.json"))
}
/// One provider entry as shaped by the models.dev registry / OpenCode cache.
#[derive(Debug, Deserialize)]
struct ModelsDevProvider {
#[serde(default)]
name: Option<String>,
#[serde(default)]
models: BTreeMap<String, ModelsDevModelIgnored>,
}
/// Per-model payload: only the key (model id) is used, so the value is parsed
/// but its fields are ignored (`deny_unknown_fields` is deliberately absent —
/// the cache carries many fields we don't need, e.g. pricing, limits).
#[derive(Debug, Deserialize)]
struct ModelsDevModelIgnored {}
/// Parses a models.dev-shaped cache payload into catalogue entries.
///
/// Returns `None` if `bytes` isn't valid JSON in the expected shape, or if it
/// parses to zero usable (non-empty-models) providers — either way the caller
/// falls back to [`static_fallback_catalogue`]. Entries are sorted by
/// `provider_id` for a deterministic, diffable order.
fn parse_models_dev_cache(bytes: &[u8]) -> Option<Vec<OpenCodeProviderCatalogEntry>> {
let providers: BTreeMap<String, ModelsDevProvider> = serde_json::from_slice(bytes).ok()?;
let mut entries: Vec<OpenCodeProviderCatalogEntry> = providers
.into_iter()
.filter_map(|(provider_id, provider)| {
let models: Vec<String> = provider.models.into_keys().collect();
if models.is_empty() {
return None;
}
let display_name = provider
.name
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| provider_id.clone());
Some(OpenCodeProviderCatalogEntry {
provider_id,
display_name,
models,
})
})
.collect();
if entries.is_empty() {
return None;
}
entries.sort_by(|a, b| a.provider_id.cmp(&b.provider_id));
Some(entries)
}
/// Builds the catalogue from raw cache bytes (`None` = cache file absent or
/// unreadable), falling back to [`static_fallback_catalogue`] whenever the
/// bytes are missing, malformed, or parse to nothing usable. Split out from
/// [`opencode_provider_catalogue`] so the fallback logic is testable without
/// touching the real filesystem.
fn catalogue_from_cache_bytes(bytes: Option<Vec<u8>>) -> Vec<OpenCodeProviderCatalogEntry> {
bytes
.and_then(|bytes| parse_models_dev_cache(&bytes))
.unwrap_or_else(static_fallback_catalogue)
}
/// Returns the OpenCode cloud-provider catalogue: the real OpenCode models
/// cache when it can be read and parsed, otherwise the static fallback.
/// Never fails — see the module docs for why the fallback is permanent, not
/// just a first-launch corner case.
#[must_use]
pub fn opencode_provider_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
let bytes = opencode_models_cache_path().and_then(|path| std::fs::read(path).ok());
catalogue_from_cache_bytes(bytes)
}
/// Use case exposing [`opencode_provider_catalogue`] to the driving side. No
/// port: the catalogue is pure static data, not something to fetch through I/O.
/// port: reading the OpenCode cache is a best-effort local convenience read,
/// never a failure mode the driving side needs to react to (see the
/// guaranteed fallback).
pub struct ListOpenCodeProviders;
/// Output of [`ListOpenCodeProviders::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListOpenCodeProvidersOutput {
/// The static catalogue entries.
/// The catalogue entries.
pub providers: Vec<OpenCodeProviderCatalogEntry>,
}
@ -64,11 +172,11 @@ impl ListOpenCodeProviders {
Self
}
/// Lists the static OpenCode cloud-provider catalogue.
/// Lists the OpenCode cloud-provider catalogue.
#[must_use]
pub fn execute(&self) -> ListOpenCodeProvidersOutput {
ListOpenCodeProvidersOutput {
providers: opencode_provider_catalogue().to_vec(),
providers: opencode_provider_catalogue(),
}
}
}
@ -84,14 +192,107 @@ mod tests {
use super::*;
#[test]
fn catalogue_entries_have_non_empty_ids_and_models() {
for entry in opencode_provider_catalogue() {
fn static_fallback_entries_have_non_empty_ids_and_models() {
for entry in static_fallback_catalogue() {
assert!(!entry.provider_id.is_empty());
assert!(!entry.display_name.is_empty());
assert!(!entry.models.is_empty());
}
}
#[test]
fn static_fallback_provider_ids_are_unique() {
let catalogue = static_fallback_catalogue();
for (i, a) in catalogue.iter().enumerate() {
for b in &catalogue[i + 1..] {
assert_ne!(a.provider_id, b.provider_id);
}
}
}
#[test]
fn catalogue_from_cache_bytes_falls_back_when_absent() {
assert_eq!(
catalogue_from_cache_bytes(None),
static_fallback_catalogue()
);
}
#[test]
fn catalogue_from_cache_bytes_falls_back_when_malformed() {
let malformed = b"{ not json at all".to_vec();
assert_eq!(
catalogue_from_cache_bytes(Some(malformed)),
static_fallback_catalogue()
);
}
#[test]
fn catalogue_from_cache_bytes_falls_back_when_empty_object() {
assert_eq!(
catalogue_from_cache_bytes(Some(b"{}".to_vec())),
static_fallback_catalogue()
);
}
#[test]
fn catalogue_from_cache_bytes_falls_back_when_no_provider_has_models() {
let json = br#"{"foo": {"name": "Foo", "models": {}}}"#.to_vec();
assert_eq!(
catalogue_from_cache_bytes(Some(json)),
static_fallback_catalogue()
);
}
#[test]
fn catalogue_from_cache_bytes_parses_a_valid_models_dev_payload() {
let json = br#"{
"openai": {
"name": "OpenAI",
"models": {
"gpt-5": {"name": "GPT-5"},
"gpt-5-mini": {"name": "GPT-5 Mini"}
}
},
"anthropic": {
"name": "Anthropic",
"models": {
"claude-sonnet-5": {"name": "Claude Sonnet 5"}
}
}
}"#
.to_vec();
let entries = catalogue_from_cache_bytes(Some(json));
// Sorted by provider_id.
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].provider_id, "anthropic");
assert_eq!(entries[0].display_name, "Anthropic");
assert_eq!(entries[0].models, vec!["claude-sonnet-5".to_owned()]);
assert_eq!(entries[1].provider_id, "openai");
assert_eq!(entries[1].display_name, "OpenAI");
let mut models = entries[1].models.clone();
models.sort();
assert_eq!(models, vec!["gpt-5".to_owned(), "gpt-5-mini".to_owned()]);
}
#[test]
fn catalogue_from_cache_bytes_defaults_display_name_to_provider_id() {
let json = br#"{"mystery": {"models": {"m1": {}}}}"#.to_vec();
let entries = catalogue_from_cache_bytes(Some(json));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].provider_id, "mystery");
assert_eq!(entries[0].display_name, "mystery");
}
#[test]
fn opencode_provider_catalogue_is_never_empty() {
// Whatever this machine's OpenCode cache state is, the catalogue is
// either the real cache or the guaranteed static fallback.
assert!(!opencode_provider_catalogue().is_empty());
}
#[test]
fn provider_ids_are_unique() {
let catalogue = opencode_provider_catalogue();
@ -103,8 +304,8 @@ mod tests {
}
#[test]
fn list_opencode_providers_returns_the_static_catalogue() {
fn list_opencode_providers_returns_a_non_empty_catalogue() {
let output = ListOpenCodeProviders::new().execute();
assert_eq!(output.providers.len(), opencode_provider_catalogue().len());
assert!(!output.providers.is_empty());
}
}

View File

@ -15,7 +15,9 @@ use std::sync::Arc;
use domain::ids::ProfileId;
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore};
use domain::profile::{AgentProfile, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
use domain::profile::{
AgentProfile, CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
};
use crate::error::AppError;
@ -293,6 +295,9 @@ pub struct SaveOpenCodeProviderProfileInput {
/// re-sealed under the profile's existing `SecretRef` on edit — never
/// persisted as a literal in `profiles.json`.
pub api_key: String,
/// Optional custom-provider configuration (endpoint outside the OpenCode
/// registry). `None` = known provider (unchanged behaviour).
pub custom: Option<CustomProviderConfig>,
}
/// Output of [`SaveOpenCodeProviderProfile::execute`].
@ -349,9 +354,12 @@ impl SaveOpenCodeProviderProfile {
self.secret_store.put(&secret_ref, &input.api_key).await?;
let provider =
let mut provider =
OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref)
.map_err(|e| AppError::Invalid(e.to_string()))?;
if let Some(custom) = input.custom {
provider = provider.with_custom(custom);
}
let mut profile = input.profile;
profile.opencode_provider = Some(provider);

View File

@ -463,6 +463,7 @@ async fn save_opencode_provider_profile_seals_the_literal_key_behind_a_secret_re
provider_id: "anthropic".to_owned(),
model: "claude-sonnet-5".to_owned(),
api_key: "sk-live-literal-secret".to_owned(),
custom: None,
})
.await
.unwrap();
@ -502,6 +503,7 @@ async fn delete_profile_with_opencode_provider_purges_its_secret() {
provider_id: "openrouter".to_owned(),
model: "anthropic/claude-sonnet-5".to_owned(),
api_key: "sk-live-to-be-purged".to_owned(),
custom: None,
})
.await
.unwrap();

View File

@ -999,7 +999,7 @@ use application::{
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput,
SaveProfileOutput,
};
use domain::profile::{AgentProfile, OpenCodeConfig};
use domain::profile::{AgentProfile, CustomProviderConfig, OpenCodeConfig};
use domain::ProfileId;
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
@ -1059,9 +1059,9 @@ pub struct OpenCodeProviderDto {
impl From<application::OpenCodeProviderCatalogEntry> for OpenCodeProviderDto {
fn from(entry: application::OpenCodeProviderCatalogEntry) -> Self {
Self {
provider_id: entry.provider_id.to_owned(),
display_name: entry.display_name.to_owned(),
models: entry.models.iter().map(|&m| m.to_owned()).collect(),
provider_id: entry.provider_id,
display_name: entry.display_name,
models: entry.models,
}
}
}
@ -1154,6 +1154,10 @@ pub struct SaveOpenCodeProviderProfileRequestDto {
pub model: String,
/// Literal API key, sealed into the `SecretStore` — never persisted as-is.
pub api_key: String,
/// Optional custom-provider configuration (endpoint outside the OpenCode
/// registry). Absent/`null` = known provider (unchanged behaviour).
#[serde(default)]
pub custom: Option<CustomProviderConfig>,
}
impl From<SaveOpenCodeProviderProfileRequestDto> for SaveOpenCodeProviderProfileInput {
@ -1163,6 +1167,7 @@ impl From<SaveOpenCodeProviderProfileRequestDto> for SaveOpenCodeProviderProfile
provider_id: dto.provider_id,
model: dto.model,
api_key: dto.api_key,
custom: dto.custom,
}
}
}

View File

@ -381,12 +381,20 @@ pub struct OpenCodeProviderConfig {
/// couche application autorisée à manipuler le littéral (mint du `SecretRef` +
/// écriture dans le `SecretStore`) construit cette référence.
pub api_key_ref: crate::ports::SecretRef,
/// Configuration additive d'un provider **non enregistré** dans le catalogue
/// OpenCode (endpoint OpenAI-compatible arbitraire). `None` = provider connu
/// du registre OpenCode (comportement historique inchangé) ; `Some` fait
/// émettre le bloc `npm`/`options.baseURL`/`models` en plus de `apiKey` côté
/// rendu JSON (voir `opencode_provider_config_json`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<CustomProviderConfig>,
}
impl OpenCodeProviderConfig {
/// Construit une configuration validée (parse-don't-validate, comme
/// [`OpenCodeConfig::new`]). Ne prend jamais de clé littérale : seule une
/// référence déjà mintée par l'application est acceptée.
/// référence déjà mintée par l'application est acceptée. `custom` est `None`
/// (provider connu) ; voir [`Self::with_custom`] pour un provider personnalisé.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
@ -403,6 +411,56 @@ impl OpenCodeProviderConfig {
provider_id,
model,
api_key_ref,
custom: None,
})
}
/// Attache une configuration de provider personnalisé (builder, additif).
#[must_use]
pub fn with_custom(mut self, custom: CustomProviderConfig) -> Self {
self.custom = Some(custom);
self
}
}
/// Configuration additive d'un provider OpenCode **personnalisé** (endpoint
/// OpenAI-compatible arbitraire, hors catalogue OpenCode), portée par
/// [`OpenCodeProviderConfig::custom`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomProviderConfig {
/// Paquet npm du SDK AI utilisé pour parler à ce provider (ex.
/// `"@ai-sdk/openai-compatible"`).
pub npm: String,
/// URL de base de l'endpoint OpenAI-compatible.
pub base_url: String,
/// Libellé optionnel affiché pour le modèle (défaut : l'id du modèle).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
impl CustomProviderConfig {
/// Construit une configuration validée (parse-don't-validate).
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `npm`, `base_url`, ou un
/// `display_name` fourni non vide après trim, est vide.
pub fn new(
npm: impl Into<String>,
base_url: impl Into<String>,
display_name: Option<String>,
) -> Result<Self, DomainError> {
let npm = npm.into();
let base_url = base_url.into();
crate::validation::non_empty(&npm, "opencodeProvider.custom.npm")?;
crate::validation::non_empty(&base_url, "opencodeProvider.custom.baseUrl")?;
if let Some(name) = &display_name {
crate::validation::non_empty(name, "opencodeProvider.custom.displayName")?;
}
Ok(Self {
npm,
base_url,
display_name,
})
}
}

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"
);
}
}