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:
@ -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
|
||||
|
||||
@ -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
1
Cargo.lock
generated
@ -98,6 +98,7 @@ name = "application"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs",
|
||||
"domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +195,7 @@ export class HttpProfileGateway implements ProfileGateway {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)`;
|
||||
|
||||
@ -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 it — it
|
||||
* 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 entry — a
|
||||
* 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,66 +556,188 @@ function OpenCodeProviderFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Provider</Caption>
|
||||
<select
|
||||
aria-label={`${profile.name} provider`}
|
||||
value={providerId}
|
||||
disabled={!catalogReady}
|
||||
onChange={(e) => {
|
||||
setProviderId(e.target.value);
|
||||
setModel("");
|
||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.providerId ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
||||
</option>
|
||||
{catalog.providers?.map((p) => (
|
||||
<option key={p.providerId} value={p.providerId}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErrors.providerId && (
|
||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||
)}
|
||||
</label>
|
||||
{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) => {
|
||||
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(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.providerId ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
||||
</option>
|
||||
{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>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<select
|
||||
aria-label={`${profile.name} model`}
|
||||
value={model}
|
||||
disabled={providerId.length === 0}
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.model ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||
</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErrors.model && (
|
||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<select
|
||||
aria-label={`${profile.name} model`}
|
||||
value={model}
|
||||
disabled={providerId.length === 0}
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.model ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||
</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErrors.model && (
|
||||
<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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user