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

@ -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,
})
}
}