From e943a0efed9345e6edcee46bc98e98afd9bd49cc Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 23 Jul 2026 12:43:59 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(backend):=20catalogue=20de=20providers?= =?UTF-8?q?=20OpenCode=20dynamique=20+=20provider=20personnalis=C3=A9=20(#?= =?UTF-8?q?92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/application/Cargo.toml | 5 + crates/application/src/agent/lifecycle.rs | 99 ++++++- .../src/agent/provider_catalogue.rs | 273 +++++++++++++++--- crates/application/src/agent/usecases.rs | 12 +- crates/application/tests/profile_usecases.rs | 2 + crates/backend/src/dto.rs | 13 +- crates/domain/src/profile.rs | 60 +++- crates/infrastructure/src/assistant/mod.rs | 93 +++++- 10 files changed, 502 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6b5c5e..da10786 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,7 @@ name = "application" version = "0.3.0" dependencies = [ "async-trait", + "dirs", "domain", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1ff5cd9..b87fcda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ futures-util = "0.3" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } hex = "0.4" sha2 = "0.10" +dirs = "6" subtle = "2" getrandom = "0.3" http = "1" diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index d6f7e27..55c9888 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -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). diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index e4ce9e4..93ddcfb 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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" + ); + } } diff --git a/crates/application/src/agent/provider_catalogue.rs b/crates/application/src/agent/provider_catalogue.rs index 87650dd..315c866 100644 --- a/crates/application/src/agent/provider_catalogue.rs +++ b/crates/application/src/agent/provider_catalogue.rs @@ -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 +//! (`/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, } -/// 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 { + 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 +/// (): 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 { + 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, + #[serde(default)] + models: BTreeMap, +} + +/// 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> { + let providers: BTreeMap = serde_json::from_slice(bytes).ok()?; + let mut entries: Vec = providers + .into_iter() + .filter_map(|(provider_id, provider)| { + let models: Vec = 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 { + 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 { + 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, } @@ -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()); } } diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index f6dcb34..fc45422 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -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, } /// 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); diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 3160c2e..2593295 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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(); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 47c7e7e..aad9c81 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -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 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, } impl From for SaveOpenCodeProviderProfileInput { @@ -1163,6 +1167,7 @@ impl From for SaveOpenCodeProviderProfile provider_id: dto.provider_id, model: dto.model, api_key: dto.api_key, + custom: dto.custom, } } } diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index c925394..cde96e3 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -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, } 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, +} + +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, + base_url: impl Into, + display_name: Option, + ) -> Result { + 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, }) } } diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index dd6a957..2319c72 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -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" + ); + } +} From 1784027f5de41b97063adcfd79fb420a5a1bec3e Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 23 Jul 2026 12:44:02 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(frontend):=20provider=20OpenCode=20dyn?= =?UTF-8?q?amique=20+=20saisie=20provider=20personnalis=C3=A9=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le picker de provider OpenCode du first-run wizard consomme le catalogue dynamique exposé par le backend et propose une option « provider personnalisé » avec un formulaire de saisie libre (id + clé API) quand le provider souhaité n'y figure pas. Co-Authored-By: Claude Opus 4.8 --- .../adapters/http/requestResponseGateways.ts | 1 + frontend/src/adapters/mock/index.ts | 1 + frontend/src/adapters/profile.ts | 1 + frontend/src/domain/index.ts | 20 ++ .../first-run/FirstRunWizard.test.tsx | 196 +++++++++++ .../src/features/first-run/FirstRunWizard.tsx | 311 ++++++++++++++---- frontend/src/ports/index.ts | 9 +- 7 files changed, 470 insertions(+), 69 deletions(-) diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 6d2b0ec..8d01553 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -195,6 +195,7 @@ export class HttpProfileGateway implements ProfileGateway { providerId: input.providerId, model: input.model, apiKey: input.apiKey, + custom: input.custom, }, }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 7e55dac..09ba941 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1374,6 +1374,7 @@ export class MockProfileGateway implements ProfileGateway { model: input.model, // The mock never seals a real secret; the ref is opaque either way. apiKeyRef: `mock-secret-${input.profile.id}`, + custom: input.custom, }, }; const i = this.profiles.findIndex((p) => p.id === saved.id); diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index 3e0ac3e..62a72f7 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway { providerId: input.providerId, model: input.model, apiKey: input.apiKey, + custom: input.custom, }, }); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index ca10a2d..e702f4f 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1015,6 +1015,26 @@ export interface OpenCodeProviderConfig { model: string; /** Opaque reference to the sealed API key; never the literal key. */ apiKeyRef: string; + /** + * Additive config for a **custom** provider (endpoint outside the OpenCode + * registry, ticket #92). Absent = known catalogue provider (unchanged + * behaviour); present = arbitrary OpenAI-compatible endpoint. + */ + custom?: CustomProviderConfig; +} + +/** + * Config for a custom OpenCode provider (mirror of the backend + * `CustomProviderConfig`, camelCase wire format), carried by + * {@link OpenCodeProviderConfig.custom}. + */ +export interface CustomProviderConfig { + /** AI SDK npm package used to talk to this provider (e.g. `"@ai-sdk/openai-compatible"`). */ + npm: string; + /** Base URL of the OpenAI-compatible endpoint. */ + baseUrl: string; + /** Optional display label for the model (defaults to the model id). */ + displayName?: string; } /** diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 116555f..ce82b9c 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -430,6 +430,202 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => { }); }); +describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic catalogue)", () => { + const OPENCODE = "OpenCode + llama.cpp"; + + async function goToCloudCustomMode() { + fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); + const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`); + fireEvent.change(providerSelect, { target: { value: "__custom__" } }); + } + + it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => { + renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull(); + expect( + screen.getByLabelText(`${OPENCODE} custom provider id`), + ).toBeTruthy(); + expect( + (screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement) + .value, + ).toBe("@ai-sdk/openai-compatible"); + expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy(); + expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy(); + expect( + screen.getByLabelText(`${OPENCODE} custom display name`), + ).toBeTruthy(); + }); + + it("validates the required custom fields on submit", async () => { + renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-custom-secret" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy(); + expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy(); + expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy(); + // npm is pre-filled by default, so it doesn't fail validation here. + expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull(); + }); + + it("saves a custom provider profile with the custom config mapped through", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), { + target: { value: "mon-provider" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), { + target: { value: "@ai-sdk/openai-compatible" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), { + target: { value: "https://api.mon-provider.example/v1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "mon-modele-1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), { + target: { value: "Mon Modèle" }, + }); + const apiKeyInput = screen.getByLabelText( + `${OPENCODE} api key`, + ) as HTMLInputElement; + fireEvent.change(apiKeyInput, { target: { value: "sk-custom-secret" } }); + + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.opencodeProvider?.providerId).toBe("mon-provider"); + expect(opencode?.opencodeProvider?.model).toBe("mon-modele-1"); + expect(opencode?.opencodeProvider?.custom).toEqual({ + npm: "@ai-sdk/openai-compatible", + baseUrl: "https://api.mon-provider.example/v1", + displayName: "Mon Modèle", + }); + }); + expect(apiKeyInput.value).toBe(""); + }); + + it("omits displayName and custom when left blank / not in custom mode", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), { + target: { value: "mon-provider" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), { + target: { value: "https://api.mon-provider.example/v1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "mon-modele-1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-custom-secret" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.opencodeProvider?.custom?.displayName).toBeUndefined(); + }); + }); + + it('"← Choisir un provider du catalogue" switches back to the cascade', async () => { + renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.click( + screen.getByRole("button", { name: "← Choisir un provider du catalogue" }), + ); + + expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy(); + expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull(); + }); + + it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => { + const profile = new MockProfileGateway(); + await profile.configureProfiles([ + { + id: "cfg-oc-custom-1", + name: "Mon provider via OpenCode", + command: "opencode", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "opencode --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "openCode", + opencodeProvider: { + providerId: "mon-provider", + model: "mon-modele-1", + apiKeyRef: "secret-ref-2", + custom: { + npm: "@ai-sdk/openai-compatible", + baseUrl: "https://api.mon-provider.example/v1", + displayName: "Mon Modèle", + }, + }, + }, + ]); + + const gateways = { + profile, + modelServer: new MockModelServerGateway(), + } as unknown as Gateways; + render( + + + , + ); + await waitForLoaded(); + + const row = within( + screen.getByLabelText("use Mon provider via OpenCode").closest("li")!, + ); + expect( + row.getByRole("radio", { name: "Provider cloud" }).getAttribute( + "aria-checked", + ), + ).toBe("true"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement) + .value, + ).toBe("mon-provider"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement) + .value, + ).toBe("@ai-sdk/openai-compatible"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement) + .value, + ).toBe("https://api.mon-provider.example/v1"); + expect( + (row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value, + ).toBe("mon-modele-1"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement) + .value, + ).toBe("Mon Modèle"); + expect( + (row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement) + .value, + ).toBe(""); + }); +}); + describe("FirstRunWizard — several local OpenCode profiles (F36)", () => { const OPENCODE = "OpenCode + llama.cpp"; const CLONE1 = `${OPENCODE} (copy 1)`; diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 5de1c05..d56632f 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -416,14 +416,25 @@ interface CloudFieldErrors { providerId?: string; model?: string; apiKey?: string; + npm?: string; + baseUrl?: string; } +/** Sentinel ` - {catalog.providers?.map((p) => ( - - ))} - - {fieldErrors.providerId && ( - {fieldErrors.providerId} - )} - + {mode === "catalog" && ( + <> + - + + + )} + + {mode === "custom" && ( +
+ + Provider personnalisé + + + + + + + + + + + + + +
+ )}