Merge feature/ticket92-opencode-provider-dynamic-catalog into develop

Complément post-livraison du ticket #92 : catalogue de providers
OpenCode dynamique (cache ~/.cache/opencode/models.json, repli
statique garanti) + option provider personnalisé en saisie libre.
QA verte : backend cargo build+test workspace, frontend tsc/build,
vitest 958/958.
This commit is contained in:
2026-07-23 12:44:17 +02:00
19 changed files with 1142 additions and 128 deletions

View File

@ -12519,10 +12519,140 @@
"ticket_id": "661fe0c9-6313-45f0-939f-34f4fecf3df4",
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
},
"state": "running",
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784789813681,
"updatedAtMs": 1784789813681,
"updatedAtMs": 1784789843631,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784789843631,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "**Décision Git — ticket #92 (rouvert) :**\n\n- L'état runtime (tickets #7/#43/#64/#92, tâche de fond, compteur/index) a été committé directement sur `develop` : `162e3ae` — pas de code de feature dedans, cohérent avec l'usage déjà en place sur cette branche.\n- Le travail précédent (`feature/ticket92-opencode-provider-cloud`) est terminé et déjà mergé (`12c7d10`) ; la branche est supprimée. Ce nouveau lot est un complément post-livraison avec un scope distinct (catalogue dynamique via cache `~/.cache/opencode/models.json` + fallback statique, option \"provider personnalisé\" en saisie libre) → **nouvelle branche de feature** plutôt qu'une reprise, pour ne pas mélanger l'historique de la livraison initiale avec ce complément.\n- Créée depuis `develop` (à jour, contient le merge du ticket #92 initial) : **`feature/ticket92-opencode-provider-dynamic-catalog`**, checkout effectué.\n\nDevBackend et DevFrontend peuvent implémenter sur cette branche. Je repasserai commiter et statuer sur le merge une fois l'implémentation terminée et testée par QA.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "401e1617-1652-4da8-88d1-784347a871d6",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"ticket_id": "f48b9320-8458-44bf-8505-22a7ee5955e8",
"conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e"
},
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784789862061,
"updatedAtMs": 1784789872415,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784789872415,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "You've hit your session limit · resets 12:20pm (Europe/Paris)",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "54bbad3a-894c-4114-bec1-79ddf862b30a",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"ticket_id": "18b42fb1-2ce8-4151-a87f-d4106721d55d",
"conversation_id": "d5068bc8-786e-0428-1d14-83b93961a15e"
},
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784802330858,
"updatedAtMs": 1784802882874,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784802882874,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "All 6 new targeted tests pass. Full workspace build and test suite (`--test-threads=1`) are green.\n\n## Summary\n\nOn `feature/ticket92-opencode-provider-dynamic-catalog`:\n\n**1. Dynamic provider catalogue** (`crates/application/src/agent/provider_catalogue.rs`)\n- `opencode_provider_catalogue()` now reads `<home>/.cache/opencode/models.json` (via `dirs::home_dir()` + literal suffix, respecting `$XDG_CACHE_HOME` — never `dirs::cache_dir()`, per OpenCode's upstream issue #8235), parses the models.dev shape, and falls back permanently to the original 3-entry static catalogue on any absence/read/parse failure.\n- Split fallback logic into a pure `catalogue_from_cache_bytes` for testability; validated the parsing against the real local OpenCode cache (`~/.cache/opencode/models.json`, 170 providers) to confirm the schema.\n- `OpenCodeProviderCatalogEntry` changed from `&'static str`/`&'static [&'static str]` to owned `String`/`Vec<String>` (no longer static data); updated `backend/src/dto.rs`'s `From` impl accordingly.\n- Added `dirs = \"6\"` as a workspace + `application` dependency (already resolved transitively, so no lockfile surprises).\n\n**2. Custom provider option**\n- `domain::profile::OpenCodeProviderConfig` gained an additive `custom: Option<CustomProviderConfig>` field (`#[serde(default, skip_serializing_if = \"Option::is_none\")]`, backward-compatible), plus a new `CustomProviderConfig` value object (`npm`, `base_url`, optional `display_name`) and a `.with_custom()` builder.\n- `SaveOpenCodeProviderProfileInput`/`SaveOpenCodeProviderProfile` extended with the same optional `custom` field, wired through `backend/src/dto.rs`'s request DTO.\n- Both `opencode_provider_config_json` implementations (`infrastructure/src/assistant/mod.rs` and `application/src/agent/lifecycle.rs`) now emit `npm`, `options.baseURL`, and a `models` block when `custom` is set, leaving the known-provider path (just `options.apiKey`) untouched.\n\nAdded unit tests for cache parsing (present/absent/malformed/empty) and for standard-vs-custom JSON generation in both duplicated sites. `cargo build --workspace` and `cargo test --workspace -- --test-threads=1` are fully green.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "b8710867-a325-4966-9c42-d7fe73b5a651",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800",
"ticket_id": "2be17edc-f009-46ee-9a26-bf70632a4109",
"conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41"
},
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784802903931,
"updatedAtMs": 1784803232024,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784803232024,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "Frontend complement of ticket #92 is implemented and green.\n\n**Summary of changes** (all in `frontend/src/`):\n- `domain/index.ts`: added `CustomProviderConfig` type and `custom?` field on `OpenCodeProviderConfig`.\n- `ports/index.ts`: added optional `custom?` to `SaveOpenCodeProviderProfileInput`.\n- `adapters/profile.ts`, `adapters/http/requestResponseGateways.ts`, `adapters/mock/index.ts`: pass `custom` through to the backend call / mock save.\n- `features/first-run/FirstRunWizard.tsx` (`OpenCodeProviderFields`): \n - Added a filter `<input>` above the provider `<select>` (shown once the catalogue is loaded) that narrows the up-to-~170 options by display name/id, while always keeping the current selection visible in the list.\n - Added an \"Autre / personnalisé…\" sentinel entry at the bottom of the provider select; picking it switches to a custom sub-form (provider id, npm package prefilled with `@ai-sdk/openai-compatible`, base URL, model id, optional display name), with a \"← Choisir un provider du catalogue\" button to switch back.\n - Inline validation extended for the two new required custom fields (npm, base URL); API key behavior (never prefilled, masked, re-entered on every save) is unchanged.\n - Edit mode: an existing profile with `opencodeProvider.custom` set preselects custom mode and prefills all custom fields except the API key.\n\n**UX calls I made without asking**, per your instructions:\n- Kept it a plain `<select>` + separate filter `<input>` rather than building a new combobox component — matches the codebase's existing \"no custom combobox\" convention and needed no new shared UI primitive.\n- Filter input only renders once the catalogue is ready (not during loading/error), reusing existing loading/error states.\n- Placed \"Autre / personnalisé…\" as the last `<option>` in the same select rather than a separate toggle, consistent with picking it out of a single list.\n\nVerified: `npx tsc --noEmit`, `npm run build`, and `npx vitest run` are all green (958 tests, including 6 new tests covering select→custom mode swap, submit validation, save mapping, blank-optional-field omission, back-to-catalogue toggle, and edit-mode prefill).",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "a333da6b-a09d-4147-a43e-5c0cae9c6a86",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"ticket_id": "f14729e1-557f-4a97-9548-2d552d7a8b33",
"conversation_id": "08336578-5b47-09d2-2f41-5cc483f101f4"
},
"state": "completed",
"wakePolicy": "recordOnly",
"createdAtMs": 1784803246008,
"updatedAtMs": 1784803415051,
"deadlineMs": null,
"result": {
"outcome": "success",
"finished_at_ms": 1784803415051,
"exit_code": null,
"summary": "Headless rendezvous completed with Final",
"stdout_tail": "## Verdict : ✅ VERT\n\n**Backend** — `cargo build --workspace` : succès. `cargo test --workspace -- --test-threads=1` : **0 échec** sur l'ensemble du workspace (des centaines de tests, tous \"ok\").\n\nVérifications de contrat :\n- **Catalogue dynamique** (`provider_catalogue.rs`) : lit `<home>/.cache/opencode/models.json` en respectant `$XDG_CACHE_HOME` (avec le bug OpenCode documenté — convention XDG même hors Linux — reproduit intentionnellement, commenté). Les 4 cas sont couverts par des tests dédiés : absent (`falls_back_when_absent`), malformé (`falls_back_when_malformed`), objet vide/illisible (`falls_back_when_empty_object`), 0 provider avec modèles (`falls_back_when_no_provider_has_models`), plus le cas valide (`parses_a_valid_models_dev_payload`). `ListOpenCodeProviders::execute()` ne prend aucun `Result` — infaillible par construction, confirmé par `opencode_provider_catalogue_is_never_empty`.\n- **Champ `custom`** additif sur `OpenCodeProviderConfig` (domain) et `SaveOpenCodeProviderProfileInput`/DTO, avec `#[serde(default, skip_serializing_if = \"Option::is_none\")]` des deux côtés → un profil existant sans `custom` sérialise/désérialise exactement comme avant (`profile_with_opencode_provider_round_trips_camelcase` le confirme implicitement, `custom: None` omis du JSON).\n- **Les deux générateurs dupliqués** (`infrastructure/src/assistant/mod.rs:424` et `application/src/agent/lifecycle.rs:2749`) sont logiquement identiques : même bloc `npm`/`options.baseURL`/`models` quand `custom` est `Some`, sinon juste `apiKey`. Testés séparément dans chaque crate (`custom_provider_emits_npm_base_url_and_models`, `..._model_name_prefers_display_name`), verdict identique des deux côtés.\n\n**Frontend** — `npx tsc --noEmit` : propre. `npm run build` : succès (aucune erreur, juste un warning de taille de chunk non lié). `npx vitest run` : **958/958 tests passés** (104 fichiers), les stacktraces \"Error: render exploded\" / \"useGateways must be used within...\" visibles dans la sortie sont des tests intentionnels de error-boundary/garde-fou, pas des échecs.\n\nVérifications de contrat :\n- Picker `OpenCodeProviderFields` (`FirstRunWizard.tsx:439`) : filtre texte au-dessus du select (garde la sélection courante visible même hors-filtre), option `\"Autre / personnalisé…\"` en dernière entrée basculant vers le sous-formulaire libre (`provider_id`, `npm` préremplig `@ai-sdk/openai-compatible`, `base_url`, `model_id`, `display_name` optionnel), validation inline par champ, clé API jamais préremplie (`useState(\"\")` inconditionnel).\n- Édition d'un profil existant avec `custom` défini : `mode` préinitialisé à `\"custom\"` si `existing?.custom`, tous les champs prérempli depuis `existing.custom.*` sauf la clé — couvert explicitement par le test `\"editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)\"`.\n\nAucun écart de contrat détecté par rapport au périmètre demandé. Seule remarque mineure (non bloquante) : `profile_usecases.rs` (application) n'a pas de test end-to-end passant `custom: Some(...)` à travers `SaveOpenCodeProviderProfile` — seul `None` y est exercé ; le passage `custom` y est un simple mapping DTO→Input déjà couvert côté domaine/infra, donc sans risque réel.",
"stderr_tail": null
},
"completionDelivered": false
},
{
"id": "eec2387a-31f0-4384-9604-b83a4010f4ae",
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"kind": {
"kind": "headlessRendezvous",
"requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641",
"target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"ticket_id": "45503854-33c2-4ddd-a5e5-94a305dabd81",
"conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4"
},
"state": "running",
"wakePolicy": "recordOnly",
"createdAtMs": 1784803425296,
"updatedAtMs": 1784803425296,
"deadlineMs": null,
"result": null,
"completionDelivered": false

View File

@ -187,6 +187,44 @@
],
"fallback": "allow"
}
},
{
"agentId": "5d07e4a7-8676-4a71-aea1-5b64caefa944",
"permissions": {
"rules": [
{
"capability": "read",
"effect": "allow",
"paths": [
"**"
],
"commands": []
},
{
"capability": "write",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "delete",
"effect": "deny",
"paths": [
"**"
],
"commands": []
},
{
"capability": "executeBash",
"effect": "allow",
"paths": [],
"commands": []
}
],
"fallback": "allow"
}
}
]
}

1
Cargo.lock generated
View File

@ -98,6 +98,7 @@ name = "application"
version = "0.3.0"
dependencies = [
"async-trait",
"dirs",
"domain",
"serde",
"serde_json",

View File

@ -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"

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

View File

@ -195,6 +195,7 @@ export class HttpProfileGateway implements ProfileGateway {
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
},
});
}

View File

@ -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);

View File

@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway {
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
},
});
}

View File

@ -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;
}
/**

View File

@ -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(
<DIProvider gateways={gateways}>
<FirstRunWizard forceOpen />
</DIProvider>,
);
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)`;

View File

@ -416,14 +416,25 @@ interface CloudFieldErrors {
providerId?: string;
model?: string;
apiKey?: string;
npm?: string;
baseUrl?: string;
}
/** Sentinel `<option>` value picking the custom-provider sub-form. */
const CUSTOM_PROVIDER_VALUE = "__custom__";
/** Default AI SDK package pre-filled for a fresh custom-provider draft. */
const DEFAULT_CUSTOM_NPM = "@ai-sdk/openai-compatible";
/**
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
* model (cascading selects fed by the static catalogue) ➜ API key. The key is
* never pre-filled (create or edit) since the backend never returns itit
* resigns whatever literal it receives on every save, so editing an existing
* cloud profile requires re-entering it every time (§3 of the spec).
* model (cascading selects fed by the dynamic catalogue, now up to ~170
* entries) ➜ API key, or — via the "Autre / personnalisé" catalogue entrya
* free-form OpenAI-compatible endpoint (provider id, npm SDK package, base
* URL, model id, optional display name). The key is never pre-filled (create
* or edit) since the backend never returns it — it reseals whatever literal
* it receives on every save, so editing an existing cloud profile requires
* re-entering it every time (§3 of the spec).
*/
function OpenCodeProviderFields({
profile,
@ -436,8 +447,17 @@ function OpenCodeProviderFields({
}) {
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [mode, setMode] = useState<"catalog" | "custom">(
existing?.custom ? "custom" : "catalog",
);
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
const [model, setModel] = useState(existing?.model ?? "");
const [providerFilter, setProviderFilter] = useState("");
const [customNpm, setCustomNpm] = useState(existing?.custom?.npm ?? DEFAULT_CUSTOM_NPM);
const [customBaseUrl, setCustomBaseUrl] = useState(existing?.custom?.baseUrl ?? "");
const [customDisplayName, setCustomDisplayName] = useState(
existing?.custom?.displayName ?? "",
);
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
@ -448,13 +468,34 @@ function OpenCodeProviderFields({
const models =
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
const catalogReady = catalog.providers !== null && !catalog.loading;
const filteredProviders = (catalog.providers ?? []).filter((p) => {
// Always keep the current selection visible even if it no longer matches
// the filter, so the <select> doesn't silently lose its value.
if (p.providerId === providerId) return true;
const q = providerFilter.trim().toLowerCase();
if (q.length === 0) return true;
return (
p.displayName.toLowerCase().includes(q) ||
p.providerId.toLowerCase().includes(q)
);
});
const saveDisabled =
saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0;
saving ||
apiKey.length === 0 ||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
async function save() {
const errors: CloudFieldErrors = {};
if (providerId.length === 0) errors.providerId = "Le provider est obligatoire.";
if (model.length === 0) errors.model = "Le modèle est obligatoire.";
if (providerId.trim().length === 0) {
errors.providerId = "Le provider est obligatoire.";
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
@ -464,9 +505,21 @@ function OpenCodeProviderFields({
try {
const saved = await profileGateway.saveOpenCodeProviderProfile({
profile,
providerId,
model,
providerId: providerId.trim(),
model: model.trim(),
apiKey,
...(mode === "custom"
? {
custom: {
npm: customNpm.trim(),
baseUrl: customBaseUrl.trim(),
displayName:
customDisplayName.trim().length > 0
? customDisplayName.trim()
: undefined,
},
}
: {}),
});
onChange(saved);
setApiKey("");
@ -503,15 +556,34 @@ function OpenCodeProviderFields({
</div>
)}
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/>
)}
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
setProviderId(e.target.value);
const v = e.target.value;
if (v === CUSTOM_PROVIDER_VALUE) {
setMode("custom");
setProviderId("");
setModel("");
} else {
setProviderId(v);
setModel("");
}
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
@ -523,11 +595,12 @@ function OpenCodeProviderFields({
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{catalog.providers?.map((p) => (
{filteredProviders.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé</option>
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
@ -563,6 +636,108 @@ function OpenCodeProviderFields({
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
</>
)}
{mode === "custom" && (
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider personnalisé
</legend>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setMode("catalog");
setProviderId("");
setModel("");
setFieldErrors({});
}}
>
Choisir un provider du catalogue
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
value={providerId}
placeholder="ex. mon-provider"
invalid={Boolean(fieldErrors.providerId)}
onChange={(e) => {
setProviderId(e.target.value);
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
/>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Paquet npm</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
value={customNpm}
placeholder={DEFAULT_CUSTOM_NPM}
invalid={Boolean(fieldErrors.npm)}
onChange={(e) => {
setCustomNpm(e.target.value);
setFieldErrors((prev) => ({ ...prev, npm: undefined }));
}}
/>
{fieldErrors.npm && (
<small className="text-xs text-danger">{fieldErrors.npm}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>URL de base</Caption>
<Input
aria-label={`${profile.name} custom base url`}
value={customBaseUrl}
placeholder="https://api.mon-provider.example/v1"
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => {
setCustomBaseUrl(e.target.value);
setFieldErrors((prev) => ({ ...prev, baseUrl: undefined }));
}}
/>
{fieldErrors.baseUrl && (
<small className="text-xs text-danger">{fieldErrors.baseUrl}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Input
aria-label={`${profile.name} model`}
value={model}
placeholder="ex. mon-modele-1"
invalid={Boolean(fieldErrors.model)}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
/>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Libellé du modèle (optionnel)</Caption>
<Input
aria-label={`${profile.name} custom display name`}
value={customDisplayName}
placeholder="ex. Mon modèle"
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
</fieldset>
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>

View File

@ -13,6 +13,7 @@ import type {
AgentDrift,
AgentProfile,
AppExitWorkGuardState,
CustomProviderConfig,
DomainEvent,
EmbedderEngines,
EmbedderProfile,
@ -701,12 +702,18 @@ export interface CloneOpenCodeProfileFromSeedInput {
export interface SaveOpenCodeProviderProfileInput {
/** The profile to create or replace (by id). */
profile: AgentProfile;
/** Provider id in the OpenCode registry (e.g. `"anthropic"`). */
/** Provider id — a catalogue entry, or a free-form id when {@link custom} is set. */
providerId: string;
/** Model name served by this provider. */
model: string;
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
apiKey: string;
/**
* Optional custom-provider configuration (ticket #92): an endpoint outside
* the OpenCode registry. Absent/`undefined` = known catalogue provider
* (unchanged behaviour).
*/
custom?: CustomProviderConfig;
}
/**