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:
@ -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).
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user