feat: implémentation et QA ticket99 - agent model configuration v2
- backend A-C: VO Codex/Claude, renderers/projecteurs modèle, SecretRef/env, catalogues/use cases/Tauri commands - frontend D: profils Codex/Claude provider->model->secret, validations, conservation SecretRef - fix: app-tauri embedded_server isolant IDEA_WEB_ROOT QA: backend 57/57 tests, frontend 74/74 tests OK
This commit is contained in:
@ -14399,6 +14399,233 @@
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "4f2b9f0a-58a3-4ad0-92d1-a55966605fb1",
|
||||
"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": "c7f81cc7-90f2-459e-9bc8-7e8f92ad1d57",
|
||||
"conversation_id": "fc710e2a-200a-471e-934f-d101bb1d2c97"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785058206414,
|
||||
"updatedAtMs": 1785058220241,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785058220241,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "Branche: `main`\nÉtat: 9 files dirty\nDécision: `git checkout -b local-impl # 100`",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "0c15bc5e-8120-43c2-b428-a581e2794515",
|
||||
"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": "67288fa3-e7e6-46b2-8796-eff59c1bd974",
|
||||
"conversation_id": "fc710e2a-200a-471e-934f-d101bb1d2c97"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785058226366,
|
||||
"updatedAtMs": 1785058262635,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785058262635,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "**Branche actuelle :** `feature/ticket99-agent-model-configuration-2` (créée et switchée)\n\n**Base :** `origin/develop` (topologie locale à vérifier)\n\n**Fichiers dirty :**\n- `.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json`\n\n**Statut :** Changement **non lié** (fichier .ideai/ système, hors du périmètre du projet).",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "e1d0c44f-478b-4e37-8324-38c04f71e2c5",
|
||||
"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": "9e451ec9-b7ca-4338-8eb1-ad5a2aedfc51",
|
||||
"conversation_id": "42b2110a-fb18-4e8b-93bd-3c83f17ca17d"
|
||||
},
|
||||
"state": "failed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785058274257,
|
||||
"updatedAtMs": 1785058874298,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "failure",
|
||||
"finished_at_ms": 1785058874298,
|
||||
"exit_code": null,
|
||||
"error": "Timeout: rendezvous inactivity window expired for target DevBackend",
|
||||
"stdout_tail": null,
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "35a0e387-11ed-4038-882d-070f50b576b8",
|
||||
"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": "0b734cd1-d547-4c59-9d72-c09f0ac5c95c",
|
||||
"conversation_id": "9e05c401-4d59-4051-a7aa-505355e4de62"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785058874527,
|
||||
"updatedAtMs": 1785059396260,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785059396260,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "Implémenté le lot frontend D du ticket #99 sur la branche `feature/ticket99-agent-model-configuration-2`.\n\nChangements principaux :\n- Ajout des types TS `CodexProviderConfig`, `ClaudeProviderConfig`, catalogues provider, custom Codex, et champs `codexProvider` / `claudeProvider` dans [frontend/src/domain/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/domain/index.ts).\n- Extension du port `ProfileGateway` avec `listCodexProviders`, `saveCodexProviderProfile`, `listClaudeProviders`, `saveClaudeProviderProfile` dans [frontend/src/ports/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/ports/index.ts).\n- Câblage des commandes backend existantes dans les adapters Tauri/HTTP :\n [profile.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/adapters/profile.ts), [requestResponseGateways.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/adapters/http/requestResponseGateways.ts).\n- Mock gateway enrichi avec catalogues Codex/Claude, profils mock structurés, sauvegarde avec `apiKeyRef` opaque et conservation du `SecretRef` existant en édition dans [frontend/src/adapters/mock/index.ts](/home/anthony/Documents/Projects/IdeA/frontend/src/adapters/mock/index.ts).\n- Hub Profils IA / first-run : sous-formes Codex/Claude provider -> modèle -> clé API scellée, validation inline, reset du modèle au changement provider, clé jamais préremplie ni conservée après save dans [FirstRunWizard.tsx](/home/anthony/Documents/Projects/IdeA/frontend/src/features/first-run/FirstRunWizard.tsx).\n- Tests #99 ajoutés pour Codex/Claude, secret non exposé et conservation `SecretRef` dans [FirstRunWizard.test.tsx](/home/anthony/Documents/Projects/IdeA/frontend/src/features/first-run/FirstRunWizard.test.tsx).\n\nCommandes exécutées :\n- `cd frontend && npx vitest run src/features/first-run/FirstRunWizard.test.tsx src/adapters/mock/profile.test.ts` -> 2 fichiers passed, 54 tests passed.\n- `cd frontend && npx vitest run` -> 104 fichiers passed, 968 tests passed. Sortie avec bruits existants de tests (`canvas.getContext`, erreurs volontairement testées), aucun échec.\n- `cd frontend && npm run typecheck` -> `tsc --noEmit` OK.\n\nLes commandes backend attendues sont présentes (`list_codex_providers`, `save_codex_provider_profile`, `list_claude_providers`, `save_claude_provider_profile`), donc pas d’attente frontend bloquante à signaler. Le worktree contient aussi des changements backend/.ideai préexistants que je n’ai pas modifiés.",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "c6194676-4186-4186-81ae-bcb2bcda2408",
|
||||
"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": "920c7f44-434b-4bf0-9f39-cc29615e2aa1",
|
||||
"conversation_id": "42b2110a-fb18-4e8b-93bd-3c83f17ca17d"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785059401387,
|
||||
"updatedAtMs": 1785059449453,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785059449453,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "Oui, côté code les lots backend A-C semblent implémentés dans le worktree, mais je ne peux pas les déclarer “verts” : je n’ai pas retrouvé de sortie `cargo test` backend exploitable pour #99, seulement le timeout de délégation DevBackend.\n\nFichiers backend modifiés :\n`crates/domain/src/profile.rs`, `crates/domain/src/permission.rs`, `crates/application/src/agent/{lifecycle.rs,mod.rs,provider_catalogue.rs,usecases.rs}`, `crates/application/src/lib.rs`, `crates/application/tests/profile_usecases.rs`, `crates/backend/src/{dto.rs,lib.rs}`, `crates/app-tauri/src/{commands.rs,lib.rs}`, `crates/infrastructure/src/permission/{codex.rs,claude.rs}`, `crates/web-server/src/lib.rs`.\n\nAussi modifiés hors backend : fichiers frontend du lot D + `.ideai/tickets/99/*`, `.ideai/tickets/index.json`, `.ideai/background-tasks/...json`.\n\nCommandes exécutées pour ce status uniquement : `git status --short`, `git log`, `git diff --stat/name-only`, lectures `issue.md`/`carnet.md`, recherches `rg`. Aucun test relancé maintenant.\n\nBlocage restant : validation QA/Rust manquante pour A-C. Le ticket #99 est encore `inProgress`, non commité.",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "1c4905b2-0ce8-4abd-848b-4171584bee3b",
|
||||
"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": "b2505084-3b68-479c-8ea9-4b6b10056bba",
|
||||
"conversation_id": "9f87ffba-d831-4371-a1e8-e3fe4bec01d7"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785059457077,
|
||||
"updatedAtMs": 1785059589057,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785059589057,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "**Verdict**\n\nKO global pour la validation complète, car une commande Rust pertinente reste rouge sur la branche `feature/ticket99-agent-model-configuration-2`, même si les invariants ticket #99 eux-mêmes sont verts sur les suites ciblées.\n\n**Échec bloquant**\n\nCommande exacte :\n```bash\ncargo test -p app-tauri\n```\n\nSortie utile :\n```text\nfailures:\n\n---- embedded_server::tests::start_is_idempotent_and_stop_stops_running_server stdout ----\nthread 'embedded_server::tests::start_is_idempotent_and_stop_stops_running_server' (27) panicked at crates/app-tauri/src/embedded_server.rs:824:63:\ncalled `Result::unwrap()` on an `Err` value: ErrorDto { code: \"INVALID\", message: \"web assets not found: build frontend/dist or set IDEA_WEB_ROOT\" }\n```\n\nFichier/ligne :\n[embedded_server.rs](/home/anthony/Documents/Projects/IdeA/crates/app-tauri/src/embedded_server.rs:824)\n\nAttendu vs obtenu :\n- Attendu : `cargo test -p app-tauri` entièrement vert.\n- Obtenu : 1 échec sur 58 tests, panique au `start()` de l’embedded server.\n\nDiagnostic :\n- L’échec ne pointe pas le ticket #99 lui-même, mais il invalide la validation complète demandée.\n- Le test prépare pourtant `IDEA_WEB_ROOT` juste avant l’appel fautif ([embedded_server.rs](/home/anthony/Documents/Projects/IdeA/crates/app-tauri/src/embedded_server.rs:809)), donc on a soit une régression d’isolation de test, soit une dépendance runtime non satisfaite dans `app-tauri`.\n- Dev concerné : `DevBackend` / surface `app-tauri`.\n\n**Ce qui est vert sur le périmètre ticket #99**\n\nCommandes exactes exécutées :\n```bash\ncargo test -p domain profile\ncargo test -p application --test profile_usecases\ncargo test -p application provider_catalogue\ncargo test -p infrastructure permission\ncargo test -p app-tauri --test dto_profiles\ncd /home/anthony/Documents/Projects/IdeA/frontend && npx vitest run src/features/first-run/FirstRunWizard.test.tsx src/features/first-run/profile.test.ts\n```\n\nRésultats :\n- `cargo test -p domain profile` : vert. Couvre les VO Codex/Claude et leur round-trip/validation, dont `codex_and_claude_provider_configs_*` dans [profile.rs](/home/anthony/Documents/Projects/IdeA/crates/domain/src/profile.rs:1018).\n- `cargo test -p application --test profile_usecases` : vert. Couvre A/C, dont secret sealing et persistance `save_codex_provider_profile_*` / `save_claude_provider_profile_*` dans [profile_usecases.rs](/home/anthony/Documents/Projects/IdeA/crates/application/tests/profile_usecases.rs:605).\n- `cargo test -p application provider_catalogue` : vert. Couvre les catalogues providers.\n- `cargo test -p infrastructure permission` : vert. Couvre B et non-régression permissions, y compris `ProjectionContext.model` avec `eff=None` pour Codex [codex.rs](/home/anthony/Documents/Projects/IdeA/crates/infrastructure/src/permission/codex.rs:275) et Claude [claude.rs](/home/anthony/Documents/Projects/IdeA/crates/infrastructure/src/permission/claude.rs:282).\n- `cargo test -p app-tauri --test dto_profiles` : vert. La surface DTO Tauri reste saine, mais ce test ne couvre pas explicitement les nouvelles commandes #99.\n- `npx vitest run ...` : vert, `74 passed`. Couvre D et la conservation du `SecretRef` dans [FirstRunWizard.test.tsx](/home/anthony/Documents/Projects/IdeA/frontend/src/features/first-run/FirstRunWizard.test.tsx:186).\n\nConclusion : le périmètre fonctionnel du ticket #99 testé est vert, mais le verdict final reste KO tant que `cargo test -p app-tauri` n’est pas repassé au vert.",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "7151d052-be14-48bc-ba79-b6bd603249d8",
|
||||
"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": "b2e46eb9-d8ff-44b5-82fa-30b4e3027c4a",
|
||||
"conversation_id": "42b2110a-fb18-4e8b-93bd-3c83f17ca17d"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785059596899,
|
||||
"updatedAtMs": 1785059712583,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785059712583,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "Corrigé. L’échec venait bien d’une dépendance de test à `IDEA_WEB_ROOT`, qui est process-wide et donc fragile avec des tests parallèles. J’ai remplacé ces usages par un `resource_dir` temporaire injecté via `EmbeddedServerController::with_resource_dir(...)`, sans toucher au runtime ni au frontend.\n\nFichier modifié :\n- [crates/app-tauri/src/embedded_server.rs](/home/anthony/Documents/Projects/IdeA/crates/app-tauri/src/embedded_server.rs)\n\nCommandes exécutées :\n- `cargo test -p app-tauri embedded_server::tests::start_is_idempotent_and_stop_stops_running_server` : OK, `1 passed`\n- `cargo test -p app-tauri` : OK, `57 passed`, `0 failed`, `1 ignored` en lib, puis toutes les suites d’intégration app-tauri vertes\n\nNote : le shell affiche toujours le bruit environnemental `Fatal Python error: Failed to import encodings module`, mais la commande Rust termine avec succès.",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "305aadaf-cd6e-41dd-8fc9-5fde243c5958",
|
||||
"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": "fe7d30b0-d781-49df-8ebb-d0b0874f922a",
|
||||
"conversation_id": "9f87ffba-d831-4371-a1e8-e3fe4bec01d7"
|
||||
},
|
||||
"state": "completed",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785059718593,
|
||||
"updatedAtMs": 1785059747719,
|
||||
"deadlineMs": null,
|
||||
"result": {
|
||||
"outcome": "success",
|
||||
"finished_at_ms": 1785059747719,
|
||||
"exit_code": null,
|
||||
"summary": "Headless rendezvous completed with Final",
|
||||
"stdout_tail": "Verdict final : OK.\n\nLa correction `embedded_server` / `IDEA_WEB_ROOT` a levé le KO précédent, et les suites minimales de revalidation backend/frontend du ticket #99 sont vertes.\n\nCommandes exactes exécutées :\n```bash\ncargo test -p app-tauri\ncargo test -p application --test profile_usecases\ncargo test -p infrastructure permission\ncd /home/anthony/Documents/Projects/IdeA/frontend && npx vitest run src/features/first-run/FirstRunWizard.test.tsx src/features/first-run/profile.test.ts\n```\n\nSorties utiles :\n```text\ncargo test -p app-tauri\ntest result: ok. 57 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out\n...\ntest embedded_server::tests::start_is_idempotent_and_stop_stops_running_server ... ok\n```\n\n```text\ncargo test -p application --test profile_usecases\ntest result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n```\n\n```text\ncargo test -p infrastructure permission\ntest result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 305 filtered out\n```\n\n```text\nnpx vitest run src/features/first-run/FirstRunWizard.test.tsx src/features/first-run/profile.test.ts\nTest Files 2 passed (2)\nTests 74 passed (74)\n```\n\nCouverture validée par ces suites :\n- Backend A/C : VO Codex/Claude, catalogues, persistance/use cases, secret sealing via `SecretRef`, non-régression OpenCode.\n- Backend B : renderers/projecteurs Codex/Claude, y compris projection du `model` avec `eff=None`, et non-régression permissions.\n- Frontend D : wizard Codex/Claude provider -> model -> secret, conservation du `SecretRef`, validations de profils.\n- Non-régression du KO précédent : `embedded_server::tests::start_is_idempotent_and_stop_stops_running_server` repasse vert dans `app-tauri`.\n\nRéserve :\n- La couche 2 gated CLI réelle du carnet #99 n’a pas été exécutée ici : aucun spawn live `codex exec` / `claude -p` ni vérification TUI réelle du modèle effectif au lancement. Le verdict est donc vert sur la couverture unitaire/intégration repo, avec cette réserve explicite sur la validation empirique des binaires CLI.",
|
||||
"stderr_tail": null
|
||||
},
|
||||
"completionDelivered": false
|
||||
},
|
||||
{
|
||||
"id": "e9b1c32b-e0c5-4c1f-a436-1c90e7cf32fb",
|
||||
"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": "c95614ee-d074-49e6-8bff-1209b5cf632f",
|
||||
"conversation_id": "fc710e2a-200a-471e-934f-d101bb1d2c97"
|
||||
},
|
||||
"state": "running",
|
||||
"wakePolicy": "recordOnly",
|
||||
"createdAtMs": 1785059758782,
|
||||
"updatedAtMs": 1785059758782,
|
||||
"deadlineMs": null,
|
||||
"result": null,
|
||||
"completionDelivered": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
issueRef: "#99"
|
||||
version: 1
|
||||
version: 3
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedAt: 1784987973464
|
||||
updatedAt: 1785059758742
|
||||
---
|
||||
# Carnet #99 — Cadrage (prêt pour le cycle)
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
id: "45733f3f-5ef5-4f33-96e7-25afddcd1ce6"
|
||||
number: 99
|
||||
title: "Modèle par agent contrôlable pour Codex & Claude (headless + TUI) — égaler le pattern OpenCode"
|
||||
status: "open"
|
||||
status: "qa"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: [{"target":"#98","kind":"relatesTo"}]
|
||||
@ -10,8 +10,8 @@ agentRefs: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
|
||||
createdAt: 1784987973464
|
||||
updatedAt: 1784987973464
|
||||
version: 1
|
||||
updatedAt: 1785059758742
|
||||
version: 3
|
||||
---
|
||||
## Problème
|
||||
|
||||
|
||||
@ -1025,11 +1025,11 @@
|
||||
"issueRef": "#99",
|
||||
"path": "99",
|
||||
"title": "Modèle par agent contrôlable pour Codex & Claude (headless + TUI) — égaler le pattern OpenCode",
|
||||
"status": "open",
|
||||
"status": "qa",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"updatedAt": 1784987973464
|
||||
"updatedAt": 1785059758742
|
||||
},
|
||||
{
|
||||
"issueRef": "#100",
|
||||
|
||||
@ -40,26 +40,27 @@ use crate::dto::{
|
||||
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
|
||||
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto,
|
||||
AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto,
|
||||
ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
|
||||
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||
ChangeAgentProfileRequestDto, ClaudeProviderListDto, CloneOpenCodeProfileFromSeedRequestDto,
|
||||
CodexProviderListDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
|
||||
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
|
||||
OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
|
||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveClaudeProviderProfileRequestDto,
|
||||
SaveCodexProviderProfileRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
@ -1188,6 +1189,22 @@ pub async fn list_opencode_providers(
|
||||
Ok(state.list_opencode_providers.execute().into())
|
||||
}
|
||||
|
||||
/// `list_codex_providers` — static catalogue of Codex providers (ticket #99).
|
||||
#[tauri::command]
|
||||
pub async fn list_codex_providers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CodexProviderListDto, ErrorDto> {
|
||||
Ok(state.list_codex_providers.execute().into())
|
||||
}
|
||||
|
||||
/// `list_claude_providers` — static catalogue of Claude providers (ticket #99).
|
||||
#[tauri::command]
|
||||
pub async fn list_claude_providers(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ClaudeProviderListDto, ErrorDto> {
|
||||
Ok(state.list_claude_providers.execute().into())
|
||||
}
|
||||
|
||||
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
|
||||
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
|
||||
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
|
||||
@ -1208,6 +1225,36 @@ pub async fn save_opencode_provider_profile(
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `save_codex_provider_profile` — create or replace a Codex profile backed by
|
||||
/// a provider/model. The literal API key is sealed into the `SecretStore`.
|
||||
#[tauri::command]
|
||||
pub async fn save_codex_provider_profile(
|
||||
request: SaveCodexProviderProfileRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileDto, ErrorDto> {
|
||||
state
|
||||
.save_codex_provider_profile
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `save_claude_provider_profile` — create or replace a Claude profile backed by
|
||||
/// a provider/model. The literal API key is sealed into the `SecretStore`.
|
||||
#[tauri::command]
|
||||
pub async fn save_claude_provider_profile(
|
||||
request: SaveClaudeProviderProfileRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileDto, ErrorDto> {
|
||||
state
|
||||
.save_claude_provider_profile
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)
|
||||
}
|
||||
|
||||
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
|
||||
/// from the canonical `opencode-llamacpp` seed/template.
|
||||
///
|
||||
|
||||
@ -623,29 +623,6 @@ mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: &Path) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
std::env::set_var(self.key, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp_app_data() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("idea-embedded-server-test-{}", Uuid::new_v4()))
|
||||
}
|
||||
@ -662,6 +639,19 @@ mod tests {
|
||||
web_root
|
||||
}
|
||||
|
||||
fn tmp_resource_dir_with_web_root() -> PathBuf {
|
||||
let resource_dir =
|
||||
std::env::temp_dir().join(format!("idea-embedded-server-resource-{}", Uuid::new_v4()));
|
||||
let web_root = resource_dir.join("web");
|
||||
std::fs::create_dir_all(&web_root).unwrap();
|
||||
std::fs::write(
|
||||
web_root.join("index.html"),
|
||||
"<!doctype html><title>IdeA</title>",
|
||||
)
|
||||
.unwrap();
|
||||
resource_dir
|
||||
}
|
||||
|
||||
fn loopback_bind_available() -> bool {
|
||||
std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).is_ok()
|
||||
}
|
||||
@ -806,9 +796,9 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let resource_dir = tmp_resource_dir_with_web_root();
|
||||
let controller =
|
||||
EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
@ -920,9 +910,9 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let resource_dir = tmp_resource_dir_with_web_root();
|
||||
let controller =
|
||||
EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
@ -979,9 +969,9 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let resource_dir = tmp_resource_dir_with_web_root();
|
||||
let controller =
|
||||
EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
@ -1006,11 +996,11 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
let app_data = tmp_app_data();
|
||||
let web_root = tmp_web_root();
|
||||
let _env = EnvVarGuard::set("IDEA_WEB_ROOT", &web_root);
|
||||
let resource_dir = tmp_resource_dir_with_web_root();
|
||||
let reserved = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = reserved.local_addr().unwrap().port();
|
||||
let controller = EmbeddedServerController::new(app_data.clone());
|
||||
let controller =
|
||||
EmbeddedServerController::with_resource_dir(app_data.clone(), Some(resource_dir));
|
||||
controller
|
||||
.save_settings(ServerExposureSettingsDto {
|
||||
mode: ServerExposureMode::LocalOnly,
|
||||
|
||||
@ -255,6 +255,10 @@ pub fn run() {
|
||||
commands::save_profile,
|
||||
commands::save_opencode_provider_profile,
|
||||
commands::list_opencode_providers,
|
||||
commands::save_codex_provider_profile,
|
||||
commands::list_codex_providers,
|
||||
commands::save_claude_provider_profile,
|
||||
commands::list_claude_providers,
|
||||
commands::clone_opencode_profile_from_seed,
|
||||
commands::delete_profile,
|
||||
commands::configure_profiles,
|
||||
|
||||
@ -20,7 +20,10 @@ use domain::ports::{
|
||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
||||
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
|
||||
};
|
||||
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
||||
use domain::profile::{
|
||||
ClaudeProviderConfig, CodexProviderConfig, McpConfigStrategy, OpenCodeProviderConfig,
|
||||
StructuredAdapter,
|
||||
};
|
||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||
use domain::{
|
||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
||||
@ -1130,6 +1133,38 @@ fn build_structured_launch_policy(
|
||||
})
|
||||
}
|
||||
|
||||
fn projection_model(profile: &AgentProfile) -> Option<&str> {
|
||||
profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.model.as_str())
|
||||
.or_else(|| {
|
||||
profile
|
||||
.claude_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.model.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn projection_model_provider(profile: &AgentProfile) -> Option<&str> {
|
||||
profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.map(|provider| provider.provider_id.as_str())
|
||||
}
|
||||
|
||||
fn projection_model_provider_base_url(profile: &AgentProfile) -> Option<&str> {
|
||||
profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.and_then(|provider| provider.custom.as_ref())
|
||||
.map(|custom| custom.base_url.as_str())
|
||||
}
|
||||
|
||||
fn projection_model_provider_env_key(profile: &AgentProfile) -> Option<&str> {
|
||||
profile.codex_provider.as_ref().map(|_| "OPENAI_API_KEY")
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
||||
///
|
||||
@ -1780,6 +1815,8 @@ impl LaunchAgent {
|
||||
|
||||
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
|
||||
.await?;
|
||||
self.apply_profile_provider_env(&profile, &run_dir, &mut spec)
|
||||
.await?;
|
||||
|
||||
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
|
||||
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
|
||||
@ -2208,6 +2245,10 @@ impl LaunchAgent {
|
||||
let ctx = ProjectionContext {
|
||||
project_root: project_root.as_str(),
|
||||
run_dir: run_dir.as_str(),
|
||||
model: projection_model(profile),
|
||||
model_provider: projection_model_provider(profile),
|
||||
model_provider_base_url: projection_model_provider_base_url(profile),
|
||||
model_provider_env_key: projection_model_provider_env_key(profile),
|
||||
};
|
||||
let projection = projector.project(permissions, network, &ctx);
|
||||
|
||||
@ -2467,7 +2508,7 @@ impl LaunchAgent {
|
||||
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
|
||||
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
|
||||
let home_dir = parent_dir(run_dir, target);
|
||||
spec.env.push((home_env.clone(), home_dir));
|
||||
upsert_env(&mut spec.env, home_env, &home_dir);
|
||||
}
|
||||
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
|
||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||
@ -2561,6 +2602,64 @@ impl LaunchAgent {
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_profile_provider_env(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(provider) = profile.codex_provider.as_ref() {
|
||||
let api_key = self.resolve_codex_provider_api_key(provider).await?;
|
||||
upsert_env(&mut spec.env, "OPENAI_API_KEY", &api_key);
|
||||
upsert_env(&mut spec.env, "CODEX_HOME", &join(run_dir, ".codex"));
|
||||
}
|
||||
if let Some(provider) = profile.claude_provider.as_ref() {
|
||||
let api_key = self.resolve_claude_provider_api_key(provider).await?;
|
||||
upsert_env(&mut spec.env, "ANTHROPIC_API_KEY", &api_key);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_codex_provider_api_key(
|
||||
&self,
|
||||
provider: &CodexProviderConfig,
|
||||
) -> Result<String, AppError> {
|
||||
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid("Codex provider profile requires a SecretStore, none injected".into())
|
||||
})?;
|
||||
secret_store
|
||||
.get(&provider.api_key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(format!(
|
||||
"no secret found for Codex provider `{}` (secret ref `{}`)",
|
||||
provider.provider_id,
|
||||
provider.api_key_ref.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_claude_provider_api_key(
|
||||
&self,
|
||||
provider: &ClaudeProviderConfig,
|
||||
) -> Result<String, AppError> {
|
||||
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
|
||||
AppError::Invalid(
|
||||
"Claude provider profile requires a SecretStore, none injected".into(),
|
||||
)
|
||||
})?;
|
||||
secret_store
|
||||
.get(&provider.api_key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid(format!(
|
||||
"no secret found for Claude provider `{}` (secret ref `{}`)",
|
||||
provider.provider_id,
|
||||
provider.api_key_ref.as_str()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_local_model_server_for_opencode(
|
||||
&self,
|
||||
agent: &Agent,
|
||||
|
||||
@ -40,8 +40,10 @@ pub use lifecycle::{
|
||||
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
|
||||
};
|
||||
pub use provider_catalogue::{
|
||||
opencode_models_cache_path, opencode_provider_catalogue, ListOpenCodeProviders,
|
||||
ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||
claude_provider_catalogue, codex_provider_catalogue, opencode_models_cache_path,
|
||||
opencode_provider_catalogue, ClaudeProviderCatalogEntry, CodexProviderCatalogEntry,
|
||||
ListClaudeProviders, ListClaudeProvidersOutput, ListCodexProviders, ListCodexProvidersOutput,
|
||||
ListOpenCodeProviders, ListOpenCodeProvidersOutput, OpenCodeProviderCatalogEntry,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
@ -52,6 +54,8 @@ pub use usecases::{
|
||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||
SaveClaudeProviderProfile, SaveClaudeProviderProfileInput, SaveClaudeProviderProfileOutput,
|
||||
SaveCodexProviderProfile, SaveCodexProviderProfileInput, SaveCodexProviderProfileOutput,
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
|
||||
@ -41,6 +41,60 @@ pub struct OpenCodeProviderCatalogEntry {
|
||||
pub(crate) model_labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One entry of a Codex provider catalogue (ticket #99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexProviderCatalogEntry {
|
||||
/// Provider identifier used as Codex's `model_provider`.
|
||||
pub provider_id: String,
|
||||
/// Human-readable label for the picker UI.
|
||||
pub display_name: String,
|
||||
/// Model identifiers this provider serves.
|
||||
pub models: Vec<String>,
|
||||
/// Whether the UI may save this provider with a custom endpoint.
|
||||
pub custom_supported: bool,
|
||||
}
|
||||
|
||||
/// One entry of a Claude provider catalogue (ticket #99).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClaudeProviderCatalogEntry {
|
||||
/// Provider identifier. V1 backend exposes Anthropic.
|
||||
pub provider_id: String,
|
||||
/// Human-readable label for the picker UI.
|
||||
pub display_name: String,
|
||||
/// Model identifiers this provider serves.
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// Returns the static Codex provider catalogue.
|
||||
#[must_use]
|
||||
pub fn codex_provider_catalogue() -> Vec<CodexProviderCatalogEntry> {
|
||||
vec![CodexProviderCatalogEntry {
|
||||
provider_id: "openai".to_owned(),
|
||||
display_name: "OpenAI".to_owned(),
|
||||
models: vec![
|
||||
"gpt-5".to_owned(),
|
||||
"gpt-5-mini".to_owned(),
|
||||
"gpt-5-codex".to_owned(),
|
||||
"o3".to_owned(),
|
||||
],
|
||||
custom_supported: true,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Returns the static Claude provider catalogue.
|
||||
#[must_use]
|
||||
pub fn claude_provider_catalogue() -> Vec<ClaudeProviderCatalogEntry> {
|
||||
vec![ClaudeProviderCatalogEntry {
|
||||
provider_id: "anthropic".to_owned(),
|
||||
display_name: "Anthropic".to_owned(),
|
||||
models: vec![
|
||||
"claude-sonnet-4-5".to_owned(),
|
||||
"claude-opus-4-1".to_owned(),
|
||||
"claude-haiku-3-5".to_owned(),
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@ -285,6 +339,70 @@ impl Default for ListOpenCodeProviders {
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the static Codex provider catalogue.
|
||||
pub struct ListCodexProviders;
|
||||
|
||||
/// Output of [`ListCodexProviders::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListCodexProvidersOutput {
|
||||
/// The catalogue entries.
|
||||
pub providers: Vec<CodexProviderCatalogEntry>,
|
||||
}
|
||||
|
||||
impl ListCodexProviders {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists the Codex provider catalogue.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListCodexProvidersOutput {
|
||||
ListCodexProvidersOutput {
|
||||
providers: codex_provider_catalogue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListCodexProviders {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case exposing the static Claude provider catalogue.
|
||||
pub struct ListClaudeProviders;
|
||||
|
||||
/// Output of [`ListClaudeProviders::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListClaudeProvidersOutput {
|
||||
/// The catalogue entries.
|
||||
pub providers: Vec<ClaudeProviderCatalogEntry>,
|
||||
}
|
||||
|
||||
impl ListClaudeProviders {
|
||||
/// Builds the use case (stateless, no ports to inject).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Lists the Claude provider catalogue.
|
||||
#[must_use]
|
||||
pub fn execute(&self) -> ListClaudeProvidersOutput {
|
||||
ListClaudeProvidersOutput {
|
||||
providers: claude_provider_catalogue(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ListClaudeProviders {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@ -16,7 +16,8 @@ use std::sync::Arc;
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore};
|
||||
use domain::profile::{
|
||||
AgentProfile, CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
|
||||
AgentProfile, ClaudeProviderConfig, CodexCustomProviderConfig, CodexProviderConfig,
|
||||
CustomProviderConfig, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -324,6 +325,149 @@ pub struct SaveOpenCodeProviderProfileOutput {
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Input for [`SaveCodexProviderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveCodexProviderProfileInput {
|
||||
/// The profile to create or replace (by id).
|
||||
pub profile: AgentProfile,
|
||||
/// Provider id used as Codex's `model_provider`.
|
||||
pub provider_id: String,
|
||||
/// Model name served by this provider.
|
||||
pub model: String,
|
||||
/// Literal API key, sealed into the `SecretStore`.
|
||||
pub api_key: String,
|
||||
/// Optional custom-provider endpoint configuration.
|
||||
pub custom: Option<CodexCustomProviderConfig>,
|
||||
}
|
||||
|
||||
/// Output of [`SaveCodexProviderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveCodexProviderProfileOutput {
|
||||
/// The saved profile (echoed back), with `codex_provider` set.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Persists a Codex profile backed by a provider/model pair (ticket #99).
|
||||
pub struct SaveCodexProviderProfile {
|
||||
profile_store: Arc<dyn ProfileStore>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl SaveCodexProviderProfile {
|
||||
/// Builds the use case from the profile store, secret store and id generator
|
||||
/// ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
profile_store: Arc<dyn ProfileStore>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
profile_store,
|
||||
secret_store,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seals `input.api_key` under a [`SecretRef`] and persists the profile with
|
||||
/// `codex_provider` set.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`]
|
||||
/// on secret or profile persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SaveCodexProviderProfileInput,
|
||||
) -> Result<SaveCodexProviderProfileOutput, AppError> {
|
||||
let secret_ref = input
|
||||
.profile
|
||||
.codex_provider
|
||||
.as_ref()
|
||||
.map(|config| config.api_key_ref.clone())
|
||||
.unwrap_or_else(|| SecretRef::new(self.ids.new_uuid().to_string()));
|
||||
self.secret_store.put(&secret_ref, &input.api_key).await?;
|
||||
|
||||
let mut provider = CodexProviderConfig::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 profile = input.profile.with_codex_provider(provider);
|
||||
self.profile_store.save(&profile).await?;
|
||||
Ok(SaveCodexProviderProfileOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`SaveClaudeProviderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveClaudeProviderProfileInput {
|
||||
/// The profile to create or replace (by id).
|
||||
pub profile: AgentProfile,
|
||||
/// Provider id. V1 backend expects `"anthropic"`.
|
||||
pub provider_id: String,
|
||||
/// Model name served by this provider.
|
||||
pub model: String,
|
||||
/// Literal API key, sealed into the `SecretStore`.
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Output of [`SaveClaudeProviderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveClaudeProviderProfileOutput {
|
||||
/// The saved profile (echoed back), with `claude_provider` set.
|
||||
pub profile: AgentProfile,
|
||||
}
|
||||
|
||||
/// Persists a Claude profile backed by a provider/model pair (ticket #99).
|
||||
pub struct SaveClaudeProviderProfile {
|
||||
profile_store: Arc<dyn ProfileStore>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl SaveClaudeProviderProfile {
|
||||
/// Builds the use case from the profile store, secret store and id generator
|
||||
/// ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
profile_store: Arc<dyn ProfileStore>,
|
||||
secret_store: Arc<dyn SecretStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
profile_store,
|
||||
secret_store,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
/// Seals `input.api_key` under a [`SecretRef`] and persists the profile with
|
||||
/// `claude_provider` set.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`]
|
||||
/// on secret or profile persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SaveClaudeProviderProfileInput,
|
||||
) -> Result<SaveClaudeProviderProfileOutput, AppError> {
|
||||
let secret_ref = input
|
||||
.profile
|
||||
.claude_provider
|
||||
.as_ref()
|
||||
.map(|config| config.api_key_ref.clone())
|
||||
.unwrap_or_else(|| SecretRef::new(self.ids.new_uuid().to_string()));
|
||||
self.secret_store.put(&secret_ref, &input.api_key).await?;
|
||||
|
||||
let provider = ClaudeProviderConfig::new(input.provider_id, input.model, secret_ref)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let profile = input.profile.with_claude_provider(provider);
|
||||
self.profile_store.save(&profile).await?;
|
||||
Ok(SaveClaudeProviderProfileOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists an OpenCode profile backed by a **cloud** provider (ticket #92, lot
|
||||
/// B3), keeping the literal API key out of `profiles.json`: it is sealed into the
|
||||
/// [`SecretStore`] under an opaque [`SecretRef`], and only the ref is persisted on
|
||||
@ -436,6 +580,12 @@ impl DeleteProfile {
|
||||
if let Some(config) = &profile.opencode_provider {
|
||||
self.secret_store.delete(&config.api_key_ref).await?;
|
||||
}
|
||||
if let Some(config) = &profile.codex_provider {
|
||||
self.secret_store.delete(&config.api_key_ref).await?;
|
||||
}
|
||||
if let Some(config) = &profile.claude_provider {
|
||||
self.secret_store.delete(&config.api_key_ref).await?;
|
||||
}
|
||||
}
|
||||
self.store.delete(input.id).await?;
|
||||
Ok(())
|
||||
|
||||
@ -44,23 +44,26 @@ pub use agent::{
|
||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
|
||||
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
||||
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
|
||||
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
ClaudeProviderCatalogEntry, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||
CloneOpenCodeProfileFromSeedOutput, CodexProviderCatalogEntry, ConfigureProfiles,
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
||||
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
||||
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider, InjectedLiveRow, InspectConversation, InspectConversationInput,
|
||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
||||
ListAgentsInput, ListAgentsOutput, ListClaudeProviders, ListClaudeProvidersOutput,
|
||||
ListCodexProviders, ListCodexProvidersOutput, ListOpenCodeProviders,
|
||||
ListOpenCodeProvidersOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
||||
OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveClaudeProviderProfile,
|
||||
SaveClaudeProviderProfileInput, SaveClaudeProviderProfileOutput, SaveCodexProviderProfile,
|
||||
SaveCodexProviderProfileInput, SaveCodexProviderProfileOutput, SaveOpenCodeProviderProfile,
|
||||
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
|
||||
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
||||
|
||||
@ -27,8 +27,9 @@ use application::{
|
||||
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
||||
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
|
||||
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
ReferenceProfiles, SaveClaudeProviderProfile, SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfile, SaveCodexProviderProfileInput, SaveOpenCodeProviderProfile,
|
||||
SaveOpenCodeProviderProfileInput, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -600,6 +601,73 @@ async fn delete_profile_with_opencode_provider_purges_its_secret() {
|
||||
assert_eq!(secrets.get(&secret_ref).await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_codex_provider_profile_seals_key_and_sets_backend_config() {
|
||||
let store = FakeProfileStore::default();
|
||||
let secrets = FakeSecretStore::default();
|
||||
let save = SaveCodexProviderProfile::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(secrets.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9901)])),
|
||||
);
|
||||
|
||||
let out = save
|
||||
.execute(SaveCodexProviderProfileInput {
|
||||
profile: profile(991, "Codex GPT-5", "codex"),
|
||||
provider_id: "openai".to_owned(),
|
||||
model: "gpt-5".to_owned(),
|
||||
api_key: "sk-openai-literal".to_owned(),
|
||||
custom: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider = out.profile.codex_provider.as_ref().unwrap();
|
||||
assert_eq!(provider.provider_id, "openai");
|
||||
assert_eq!(provider.model, "gpt-5");
|
||||
assert_ne!(provider.api_key_ref.as_str(), "sk-openai-literal");
|
||||
assert!(!serde_json::to_string(&out.profile)
|
||||
.unwrap()
|
||||
.contains("sk-openai-literal"));
|
||||
assert_eq!(
|
||||
secrets.get(&provider.api_key_ref).await.unwrap(),
|
||||
Some("sk-openai-literal".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_claude_provider_profile_seals_key_and_sets_backend_config() {
|
||||
let store = FakeProfileStore::default();
|
||||
let secrets = FakeSecretStore::default();
|
||||
let save = SaveClaudeProviderProfile::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(secrets.clone()),
|
||||
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9902)])),
|
||||
);
|
||||
|
||||
let out = save
|
||||
.execute(SaveClaudeProviderProfileInput {
|
||||
profile: profile(992, "Claude Sonnet", "claude"),
|
||||
provider_id: "anthropic".to_owned(),
|
||||
model: "claude-sonnet-4-5".to_owned(),
|
||||
api_key: "sk-anthropic-literal".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider = out.profile.claude_provider.as_ref().unwrap();
|
||||
assert_eq!(provider.provider_id, "anthropic");
|
||||
assert_eq!(provider.model, "claude-sonnet-4-5");
|
||||
assert_ne!(provider.api_key_ref.as_str(), "sk-anthropic-literal");
|
||||
assert!(!serde_json::to_string(&out.profile)
|
||||
.unwrap()
|
||||
.contains("sk-anthropic-literal"));
|
||||
assert_eq!(
|
||||
secrets.get(&provider.api_key_ref).await.unwrap(),
|
||||
Some("sk-anthropic-literal".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
||||
let store = FakeProfileStore::default();
|
||||
|
||||
@ -999,10 +999,13 @@ use application::{
|
||||
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
||||
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
||||
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
||||
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput,
|
||||
SaveProfileOutput,
|
||||
SaveClaudeProviderProfileInput, SaveClaudeProviderProfileOutput, SaveCodexProviderProfileInput,
|
||||
SaveCodexProviderProfileOutput, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfileInput, SaveProfileOutput,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, CodexCustomProviderConfig, CustomProviderConfig, OpenCodeConfig,
|
||||
};
|
||||
use domain::profile::{AgentProfile, CustomProviderConfig, OpenCodeConfig};
|
||||
use domain::ProfileId;
|
||||
|
||||
/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
|
||||
@ -1069,6 +1072,68 @@ impl From<application::OpenCodeProviderCatalogEntry> for OpenCodeProviderDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the static Codex provider catalogue (ticket #99).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexProviderDto {
|
||||
pub provider_id: String,
|
||||
pub display_name: String,
|
||||
pub models: Vec<String>,
|
||||
pub custom_supported: bool,
|
||||
}
|
||||
|
||||
impl From<application::CodexProviderCatalogEntry> for CodexProviderDto {
|
||||
fn from(entry: application::CodexProviderCatalogEntry) -> Self {
|
||||
Self {
|
||||
provider_id: entry.provider_id,
|
||||
display_name: entry.display_name,
|
||||
models: entry.models,
|
||||
custom_supported: entry.custom_supported,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of Codex provider catalogue entries.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct CodexProviderListDto(pub Vec<CodexProviderDto>);
|
||||
|
||||
impl From<application::ListCodexProvidersOutput> for CodexProviderListDto {
|
||||
fn from(out: application::ListCodexProvidersOutput) -> Self {
|
||||
Self(out.providers.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the static Claude provider catalogue (ticket #99).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClaudeProviderDto {
|
||||
pub provider_id: String,
|
||||
pub display_name: String,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<application::ClaudeProviderCatalogEntry> for ClaudeProviderDto {
|
||||
fn from(entry: application::ClaudeProviderCatalogEntry) -> Self {
|
||||
Self {
|
||||
provider_id: entry.provider_id,
|
||||
display_name: entry.display_name,
|
||||
models: entry.models,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of Claude provider catalogue entries.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ClaudeProviderListDto(pub Vec<ClaudeProviderDto>);
|
||||
|
||||
impl From<application::ListClaudeProvidersOutput> for ClaudeProviderListDto {
|
||||
fn from(out: application::ListClaudeProvidersOutput) -> Self {
|
||||
Self(out.providers.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of OpenCode cloud-provider catalogue entries (camelCase array on the
|
||||
/// wire).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@ -1181,6 +1246,63 @@ impl From<SaveOpenCodeProviderProfileOutput> for ProfileDto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `save_codex_provider_profile` (ticket #99).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveCodexProviderProfileRequestDto {
|
||||
pub profile: AgentProfile,
|
||||
pub provider_id: String,
|
||||
pub model: String,
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub custom: Option<CodexCustomProviderConfig>,
|
||||
}
|
||||
|
||||
impl From<SaveCodexProviderProfileRequestDto> for SaveCodexProviderProfileInput {
|
||||
fn from(dto: SaveCodexProviderProfileRequestDto) -> Self {
|
||||
Self {
|
||||
profile: dto.profile,
|
||||
provider_id: dto.provider_id,
|
||||
model: dto.model,
|
||||
api_key: dto.api_key,
|
||||
custom: dto.custom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SaveCodexProviderProfileOutput> for ProfileDto {
|
||||
fn from(out: SaveCodexProviderProfileOutput) -> Self {
|
||||
Self(out.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `save_claude_provider_profile` (ticket #99).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveClaudeProviderProfileRequestDto {
|
||||
pub profile: AgentProfile,
|
||||
pub provider_id: String,
|
||||
pub model: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
impl From<SaveClaudeProviderProfileRequestDto> for SaveClaudeProviderProfileInput {
|
||||
fn from(dto: SaveClaudeProviderProfileRequestDto) -> Self {
|
||||
Self {
|
||||
profile: dto.profile,
|
||||
provider_id: dto.provider_id,
|
||||
model: dto.model,
|
||||
api_key: dto.api_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SaveClaudeProviderProfileOutput> for ProfileDto {
|
||||
fn from(out: SaveClaudeProviderProfileOutput) -> Self {
|
||||
Self(out.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `clone_opencode_profile_from_seed`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@ -26,13 +26,13 @@ use application::{
|
||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HarvestMemoryFromTurn, HealthUseCase, InspectConversation, InstallPluginFromArchive,
|
||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues,
|
||||
ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders,
|
||||
ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents,
|
||||
ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider,
|
||||
LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue,
|
||||
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListClaudeProviders, ListCodexProviders, ListDevices,
|
||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory,
|
||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts,
|
||||
@ -40,17 +40,17 @@ use application::{
|
||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage,
|
||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer,
|
||||
SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout,
|
||||
SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext,
|
||||
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
|
||||
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions,
|
||||
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveClaudeProviderProfile,
|
||||
SaveCodexProviderProfile, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile,
|
||||
SaveProfile, SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
|
||||
SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
|
||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions,
|
||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
@ -938,8 +938,18 @@ pub struct BackendCore {
|
||||
/// Save (upsert) an OpenCode profile backed by a cloud provider, sealing its
|
||||
/// literal API key into the [`SecretStore`] (ticket #92, lot B3).
|
||||
pub save_opencode_provider_profile: Arc<SaveOpenCodeProviderProfile>,
|
||||
/// Save (upsert) a Codex profile backed by a provider/model, sealing its
|
||||
/// literal API key into the [`SecretStore`] (ticket #99).
|
||||
pub save_codex_provider_profile: Arc<SaveCodexProviderProfile>,
|
||||
/// Save (upsert) a Claude profile backed by a provider/model, sealing its
|
||||
/// literal API key into the [`SecretStore`] (ticket #99).
|
||||
pub save_claude_provider_profile: Arc<SaveClaudeProviderProfile>,
|
||||
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
||||
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
||||
/// Static catalogue of Codex providers (ticket #99).
|
||||
pub list_codex_providers: Arc<ListCodexProviders>,
|
||||
/// Static catalogue of Claude providers (ticket #99).
|
||||
pub list_claude_providers: Arc<ListClaudeProviders>,
|
||||
/// Create a new OpenCode profile instance from the canonical seed.
|
||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||
/// Delete a profile.
|
||||
@ -1467,7 +1477,19 @@ impl BackendCore {
|
||||
Arc::clone(&secret_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let save_codex_provider_profile = Arc::new(SaveCodexProviderProfile::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&secret_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let save_claude_provider_profile = Arc::new(SaveClaudeProviderProfile::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&secret_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
||||
let list_codex_providers = Arc::new(ListCodexProviders::new());
|
||||
let list_claude_providers = Arc::new(ListClaudeProviders::new());
|
||||
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||
Arc::clone(&profile_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
@ -2660,7 +2682,11 @@ impl BackendCore {
|
||||
list_profiles,
|
||||
save_profile,
|
||||
save_opencode_provider_profile,
|
||||
save_codex_provider_profile,
|
||||
save_claude_provider_profile,
|
||||
list_opencode_providers,
|
||||
list_codex_providers,
|
||||
list_claude_providers,
|
||||
clone_opencode_profile_from_seed,
|
||||
delete_profile,
|
||||
configure_profiles,
|
||||
|
||||
@ -850,6 +850,15 @@ pub struct ProjectionContext<'a> {
|
||||
pub project_root: &'a str,
|
||||
/// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
|
||||
pub run_dir: &'a str,
|
||||
/// Optional model selected by the agent profile. Orthogonal to permissions:
|
||||
/// projectors may still materialise it even when `eff == None`.
|
||||
pub model: Option<&'a str>,
|
||||
/// Optional Codex `model_provider` value selected by the profile.
|
||||
pub model_provider: Option<&'a str>,
|
||||
/// Optional base URL for a custom Codex model provider.
|
||||
pub model_provider_base_url: Option<&'a str>,
|
||||
/// Optional API-key environment variable name for a model provider.
|
||||
pub model_provider_env_key: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// One file a projector wants materialised at launch, tagged by **ownership**.
|
||||
|
||||
@ -423,6 +423,125 @@ impl OpenCodeProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration additive d'un provider Codex personnalisé (endpoint
|
||||
/// OpenAI-compatible arbitraire, hors providers natifs Codex).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexCustomProviderConfig {
|
||||
/// URL de base de l'endpoint OpenAI-compatible.
|
||||
pub base_url: String,
|
||||
/// Libellé optionnel écrit dans la table `model_providers`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexCustomProviderConfig {
|
||||
/// Construit une configuration validée (parse-don't-validate).
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] si `base_url`, ou un `display_name`
|
||||
/// fourni non vide après trim, est vide.
|
||||
pub fn new(
|
||||
base_url: impl Into<String>,
|
||||
display_name: Option<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let base_url = base_url.into();
|
||||
crate::validation::non_empty(&base_url, "codexProvider.custom.baseUrl")?;
|
||||
if let Some(name) = &display_name {
|
||||
crate::validation::non_empty(name, "codexProvider.custom.displayName")?;
|
||||
}
|
||||
Ok(Self {
|
||||
base_url,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration déclarative d'un profil Codex contrôlé par IdeA.
|
||||
///
|
||||
/// Le modèle est rendu dans le `$CODEX_HOME/config.toml` isolé du run dir, et la
|
||||
/// clé API réelle reste dans le [`crate::ports::SecretStore`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexProviderConfig {
|
||||
/// Identifiant du provider Codex (`"openai"` ou un provider custom).
|
||||
pub provider_id: String,
|
||||
/// Nom du modèle servi par ce provider.
|
||||
pub model: String,
|
||||
/// Référence opaque vers la clé API réelle, jamais persistée en clair.
|
||||
pub api_key_ref: crate::ports::SecretRef,
|
||||
/// Configuration additive d'un provider personnalisé.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<CodexCustomProviderConfig>,
|
||||
}
|
||||
|
||||
impl CodexProviderConfig {
|
||||
/// Construit une configuration validée.
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
|
||||
pub fn new(
|
||||
provider_id: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
api_key_ref: crate::ports::SecretRef,
|
||||
) -> Result<Self, DomainError> {
|
||||
let provider_id = provider_id.into();
|
||||
let model = model.into();
|
||||
crate::validation::non_empty(&provider_id, "codexProvider.providerId")?;
|
||||
crate::validation::non_empty(&model, "codexProvider.model")?;
|
||||
Ok(Self {
|
||||
provider_id,
|
||||
model,
|
||||
api_key_ref,
|
||||
custom: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Attache une configuration de provider personnalisé.
|
||||
#[must_use]
|
||||
pub fn with_custom(mut self, custom: CodexCustomProviderConfig) -> Self {
|
||||
self.custom = Some(custom);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration déclarative d'un profil Claude contrôlé par IdeA.
|
||||
///
|
||||
/// Le modèle est rendu dans le `.claude/settings.local.json` isolé du run dir, et
|
||||
/// la clé API réelle reste dans le [`crate::ports::SecretStore`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClaudeProviderConfig {
|
||||
/// Identifiant du provider Claude. V1 backend: `"anthropic"`.
|
||||
pub provider_id: String,
|
||||
/// Nom du modèle Claude à poser au lancement.
|
||||
pub model: String,
|
||||
/// Référence opaque vers la clé API réelle, jamais persistée en clair.
|
||||
pub api_key_ref: crate::ports::SecretRef,
|
||||
}
|
||||
|
||||
impl ClaudeProviderConfig {
|
||||
/// Construit une configuration validée.
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
|
||||
pub fn new(
|
||||
provider_id: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
api_key_ref: crate::ports::SecretRef,
|
||||
) -> Result<Self, DomainError> {
|
||||
let provider_id = provider_id.into();
|
||||
let model = model.into();
|
||||
crate::validation::non_empty(&provider_id, "claudeProvider.providerId")?;
|
||||
crate::validation::non_empty(&model, "claudeProvider.model")?;
|
||||
Ok(Self {
|
||||
provider_id,
|
||||
model,
|
||||
api_key_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration additive d'un provider OpenCode **personnalisé** (endpoint
|
||||
/// OpenAI-compatible arbitraire, hors catalogue OpenCode), portée par
|
||||
/// [`OpenCodeProviderConfig::custom`].
|
||||
@ -896,6 +1015,16 @@ pub struct AgentProfile {
|
||||
/// avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub opencode_provider: Option<OpenCodeProviderConfig>,
|
||||
/// Configuration Codex provider/modèle (ticket #99). `None` pour les profils
|
||||
/// non-Codex et les profils Codex historiques qui gardent le défaut natif de
|
||||
/// la CLI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex_provider: Option<CodexProviderConfig>,
|
||||
/// Configuration Claude provider/modèle (ticket #99). `None` pour les profils
|
||||
/// non-Claude et les profils Claude historiques qui gardent le défaut natif de
|
||||
/// la CLI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_provider: Option<ClaudeProviderConfig>,
|
||||
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||
@ -1102,6 +1231,8 @@ impl AgentProfile {
|
||||
chat_http: None,
|
||||
opencode: None,
|
||||
opencode_provider: None,
|
||||
codex_provider: None,
|
||||
claude_provider: None,
|
||||
mcp: None,
|
||||
liveness: None,
|
||||
rate_limit_pattern: None,
|
||||
@ -1152,6 +1283,20 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la configuration Codex provider/modèle (ticket #99).
|
||||
#[must_use]
|
||||
pub fn with_codex_provider(mut self, config: CodexProviderConfig) -> Self {
|
||||
self.codex_provider = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la configuration Claude provider/modèle (ticket #99).
|
||||
#[must_use]
|
||||
pub fn with_claude_provider(mut self, config: ClaudeProviderConfig) -> Self {
|
||||
self.claude_provider = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans MCP ne l'appellent simplement pas.
|
||||
@ -1462,6 +1607,53 @@ mod mcp_tests {
|
||||
assert_eq!(back.opencode_provider, Some(provider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_and_claude_provider_configs_round_trip_camelcase() {
|
||||
let codex = CodexProviderConfig::new(
|
||||
"openai",
|
||||
"gpt-5",
|
||||
crate::ports::SecretRef::new("secret-openai"),
|
||||
)
|
||||
.unwrap()
|
||||
.with_custom(
|
||||
CodexCustomProviderConfig::new(
|
||||
"https://models.example.test/v1",
|
||||
Some("Example".to_owned()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let claude = ClaudeProviderConfig::new(
|
||||
"anthropic",
|
||||
"claude-sonnet-4-5",
|
||||
crate::ports::SecretRef::new("secret-anthropic"),
|
||||
)
|
||||
.unwrap();
|
||||
let profile = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_codex_provider(codex.clone())
|
||||
.with_claude_provider(claude.clone());
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("\"codexProvider\""), "got: {json}");
|
||||
assert!(json.contains("\"claudeProvider\""), "got: {json}");
|
||||
assert!(json.contains("\"providerId\":\"openai\""), "got: {json}");
|
||||
assert!(json.contains("\"baseUrl\""), "got: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(back.codex_provider, Some(codex));
|
||||
assert_eq!(back.claude_provider, Some(claude));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_and_claude_provider_configs_reject_empty_fields() {
|
||||
let secret_ref = crate::ports::SecretRef::new("secret-1");
|
||||
assert!(CodexProviderConfig::new("", "gpt-5", secret_ref.clone()).is_err());
|
||||
assert!(CodexProviderConfig::new("openai", "", secret_ref.clone()).is_err());
|
||||
assert!(ClaudeProviderConfig::new("", "claude-sonnet-4-5", secret_ref.clone()).is_err());
|
||||
assert!(ClaudeProviderConfig::new("anthropic", "", secret_ref).is_err());
|
||||
assert!(CodexCustomProviderConfig::new("", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_backend_consistency_rejects_both_configs_set() {
|
||||
let local = OpenCodeConfig::new(
|
||||
|
||||
@ -34,11 +34,12 @@ impl PermissionProjector for ClaudePermissionProjector {
|
||||
_network: Option<domain::NetworkPolicy>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
|
||||
let Some(_) = eff else {
|
||||
// Product invariant: no permissions and no model ⇒ nothing projected
|
||||
// (native prompting). A model is orthogonal and still gets materialised.
|
||||
if eff.is_none() && ctx.model.is_none() {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let contents = claude_settings_seed(ctx.project_root, eff);
|
||||
}
|
||||
let contents = claude_settings_seed(ctx.project_root, eff, ctx.model);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: SETTINGS_REL_PATH.to_owned(),
|
||||
@ -57,7 +58,22 @@ impl PermissionProjector for ClaudePermissionProjector {
|
||||
/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
|
||||
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
|
||||
/// the run dir and the agent works on the root above it.
|
||||
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
|
||||
fn claude_settings_seed(
|
||||
project_root: &str,
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
model: Option<&str>,
|
||||
) -> String {
|
||||
let model_line = model
|
||||
.map(|model| format!(" \"model\": {},\n", json_literal(model)))
|
||||
.unwrap_or_default();
|
||||
if permissions.is_none() {
|
||||
return format!(
|
||||
r#"{{
|
||||
{model_line} "enabledMcpjsonServers": ["idea"]
|
||||
}}
|
||||
"#
|
||||
);
|
||||
}
|
||||
let root = json_escape(project_root);
|
||||
let default_mode = match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Deny) => "plan",
|
||||
@ -80,7 +96,7 @@ fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermis
|
||||
let deny = json_string_array(&merge_default_deny(deny));
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
{model_line} "permissions": {{
|
||||
"defaultMode": "{default_mode}",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
@ -197,6 +213,10 @@ fn json_string_array(items: &[String]) -> String {
|
||||
format!("[\n{body}\n ]")
|
||||
}
|
||||
|
||||
fn json_literal(value: &str) -> String {
|
||||
format!("\"{}\"", json_escape(value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -207,6 +227,10 @@ mod tests {
|
||||
ProjectionContext {
|
||||
project_root: root,
|
||||
run_dir,
|
||||
model: None,
|
||||
model_provider: None,
|
||||
model_provider_base_url: None,
|
||||
model_provider_env_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,6 +279,31 @@ mod tests {
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_projects_even_without_permissions() {
|
||||
let ctx = ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run",
|
||||
model: Some("claude-sonnet-4-5"),
|
||||
model_provider: None,
|
||||
model_provider_base_url: None,
|
||||
model_provider_env_key: None,
|
||||
};
|
||||
let proj = ClaudePermissionProjector.project(None, None, &ctx);
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
assert_eq!(proj.files.len(), 1);
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::Replace { rel_path, contents } => {
|
||||
assert_eq!(rel_path, SETTINGS_REL_PATH);
|
||||
let json: Value = serde_json::from_str(contents).unwrap();
|
||||
assert_eq!(json["model"], "claude-sonnet-4-5");
|
||||
assert!(json.get("permissions").is_none());
|
||||
}
|
||||
ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_the_settings_file() {
|
||||
assert_eq!(
|
||||
|
||||
@ -30,7 +30,7 @@ const CONFIG_REL_PATH: &str = ".codex/config.toml";
|
||||
|
||||
/// The top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
const PERMISSION_MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// Codex workspace-write sandbox table owned by IdeA for network projection.
|
||||
const SANDBOX_WORKSPACE_WRITE_TABLE: &str = "sandbox_workspace_write";
|
||||
@ -57,7 +57,7 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
// orthogonal and still gets an explicit env override to avoid stale inheritance.
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection {
|
||||
files: vec![codex_network_file(network)],
|
||||
files: vec![codex_model_and_network_file(ctx, network)],
|
||||
env: codex_network_env(network),
|
||||
..PermissionProjection::empty()
|
||||
};
|
||||
@ -69,13 +69,14 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
// Permission-only TOML fragment (escaped exactly like the former
|
||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||
// permission concern and stay with the MCP wiring (LP3-3).
|
||||
let contents = format!(
|
||||
let mut contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
||||
network_access,
|
||||
);
|
||||
append_codex_model_config(&mut contents, ctx);
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox".to_owned(),
|
||||
@ -91,8 +92,8 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
managed_tables: codex_managed_tables(ctx),
|
||||
managed_keys: codex_managed_keys(ctx, true),
|
||||
contents,
|
||||
}],
|
||||
args,
|
||||
@ -106,19 +107,77 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_network_file(network: Option<NetworkPolicy>) -> ProjectedFile {
|
||||
fn codex_model_and_network_file(
|
||||
ctx: &ProjectionContext,
|
||||
network: Option<NetworkPolicy>,
|
||||
) -> ProjectedFile {
|
||||
let mut contents = format!(
|
||||
"[{}]\nnetwork_access = {}\n",
|
||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
||||
codex_network_access(network),
|
||||
);
|
||||
append_codex_model_config(&mut contents, ctx);
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
|
||||
managed_keys: Vec::new(),
|
||||
contents: format!(
|
||||
"[{}]\nnetwork_access = {}\n",
|
||||
SANDBOX_WORKSPACE_WRITE_TABLE,
|
||||
codex_network_access(network),
|
||||
),
|
||||
managed_tables: codex_managed_tables(ctx),
|
||||
managed_keys: codex_managed_keys(ctx, false),
|
||||
contents,
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
if include_permissions {
|
||||
keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned()));
|
||||
}
|
||||
if ctx.model.is_some() {
|
||||
keys.push("model".to_owned());
|
||||
}
|
||||
if ctx.model_provider.is_some() {
|
||||
keys.push("model_provider".to_owned());
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn codex_managed_tables(ctx: &ProjectionContext) -> Vec<String> {
|
||||
let mut tables = vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()];
|
||||
if let Some(provider) = ctx.model_provider {
|
||||
if ctx.model_provider_base_url.is_some() {
|
||||
tables.push(codex_model_provider_table(provider));
|
||||
}
|
||||
}
|
||||
tables
|
||||
}
|
||||
|
||||
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
|
||||
if let Some(model) = ctx.model {
|
||||
contents.push_str(&format!("model = {}\n", toml_string(model)));
|
||||
}
|
||||
let Some(provider) = ctx.model_provider else {
|
||||
return;
|
||||
};
|
||||
contents.push_str(&format!("model_provider = {}\n", toml_string(provider)));
|
||||
if let Some(base_url) = ctx.model_provider_base_url {
|
||||
let table = codex_model_provider_table(provider);
|
||||
let name = provider_display_name(ctx);
|
||||
let env_key = ctx.model_provider_env_key.unwrap_or("OPENAI_API_KEY");
|
||||
contents.push_str(&format!(
|
||||
"\n[{table}]\nname = {}\nbase_url = {}\nenv_key = {}\nwire_api = \"responses\"\n",
|
||||
toml_string(name),
|
||||
toml_string(base_url),
|
||||
toml_string(env_key),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_display_name<'a>(ctx: &'a ProjectionContext<'a>) -> &'a str {
|
||||
ctx.model_provider.unwrap_or("custom")
|
||||
}
|
||||
|
||||
fn codex_model_provider_table(provider: &str) -> String {
|
||||
format!("model_providers.{}", toml_string(provider))
|
||||
}
|
||||
|
||||
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
|
||||
// Codex inherits the parent environment by default. Always set the variable for
|
||||
// Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
|
||||
@ -161,6 +220,10 @@ mod tests {
|
||||
ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run/agent",
|
||||
model: None,
|
||||
model_provider: None,
|
||||
model_provider_base_url: None,
|
||||
model_provider_env_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,6 +272,52 @@ mod tests {
|
||||
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_projection_without_permissions_writes_model_provider_and_custom_table() {
|
||||
let ctx = ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run/agent",
|
||||
model: Some("gpt-5"),
|
||||
model_provider: Some("openai-compatible"),
|
||||
model_provider_base_url: Some("https://models.example.test/v1"),
|
||||
model_provider_env_key: Some("OPENAI_API_KEY"),
|
||||
};
|
||||
let proj = CodexPermissionProjector.project(None, None, &ctx);
|
||||
assert!(proj.args.is_empty());
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml {
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
..
|
||||
} => {
|
||||
assert!(managed_keys.contains(&"model".to_owned()));
|
||||
assert!(managed_keys.contains(&"model_provider".to_owned()));
|
||||
assert!(
|
||||
managed_tables.contains(&"model_providers.\"openai-compatible\"".to_owned())
|
||||
);
|
||||
assert!(contents.contains("model = \"gpt-5\""), "{contents}");
|
||||
assert!(
|
||||
contents.contains("model_provider = \"openai-compatible\""),
|
||||
"{contents}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("[model_providers.\"openai-compatible\"]"),
|
||||
"{contents}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("base_url = \"https://models.example.test/v1\""),
|
||||
"{contents}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("env_key = \"OPENAI_API_KEY\""),
|
||||
"{contents}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + add-dir + MergeToml shape ---------------------
|
||||
|
||||
|
||||
@ -69,23 +69,25 @@ use backend::dto::{
|
||||
parse_sprint_id_dto, parse_sprint_status_dto, parse_task_id, parse_template_id,
|
||||
sort_ticket_rows, sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto,
|
||||
AgentListDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
|
||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateMemoryRequestDto,
|
||||
CreateSkillRequestDto, CreateTemplateRequestDto, DetectProfilesRequestDto,
|
||||
DetectProfilesResponseDto, EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||
MemoryListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
|
||||
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
|
||||
RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveProfileRequestDto, SkillDto,
|
||||
SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto,
|
||||
SprintListRequestDto, SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ClaudeProviderListDto,
|
||||
CloneOpenCodeProfileFromSeedRequestDto, CodexProviderListDto, ConfigureProfilesRequestDto,
|
||||
ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto,
|
||||
CreateMemoryRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
|
||||
GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto,
|
||||
GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, MemoryDto,
|
||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||
ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
|
||||
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveClaudeProviderProfileRequestDto,
|
||||
SaveCodexProviderProfileRequestDto, SaveEmbedderProfileRequestDto,
|
||||
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
|
||||
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
||||
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
|
||||
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
|
||||
@ -2351,6 +2353,18 @@ async fn invoke(
|
||||
"detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await,
|
||||
"list_profiles" => invoke_list_profiles(&state.app).await,
|
||||
"save_profile" => invoke_save_profile(&request.args, &state.app).await,
|
||||
"list_opencode_providers" => invoke_list_opencode_providers(&state.app),
|
||||
"save_opencode_provider_profile" => {
|
||||
invoke_save_opencode_provider_profile(&request.args, &state.app).await
|
||||
}
|
||||
"list_codex_providers" => invoke_list_codex_providers(&state.app),
|
||||
"save_codex_provider_profile" => {
|
||||
invoke_save_codex_provider_profile(&request.args, &state.app).await
|
||||
}
|
||||
"list_claude_providers" => invoke_list_claude_providers(&state.app),
|
||||
"save_claude_provider_profile" => {
|
||||
invoke_save_claude_provider_profile(&request.args, &state.app).await
|
||||
}
|
||||
"delete_profile" => invoke_delete_profile(&request.args, &state.app).await,
|
||||
"configure_profiles" => invoke_configure_profiles(&request.args, &state.app).await,
|
||||
"clone_opencode_profile_from_seed" => {
|
||||
@ -2596,6 +2610,72 @@ async fn invoke_save_profile(args: &Value, state: &BackendCore) -> Result<Value,
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
fn invoke_list_opencode_providers(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output: OpenCodeProviderListDto = state.list_opencode_providers.execute().into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_save_opencode_provider_profile(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<SaveOpenCodeProviderProfileRequestDto>(
|
||||
"save_opencode_provider_profile",
|
||||
args,
|
||||
)?;
|
||||
let output = state
|
||||
.save_opencode_provider_profile
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
fn invoke_list_codex_providers(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output: CodexProviderListDto = state.list_codex_providers.execute().into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_save_codex_provider_profile(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<SaveCodexProviderProfileRequestDto>(
|
||||
"save_codex_provider_profile",
|
||||
args,
|
||||
)?;
|
||||
let output = state
|
||||
.save_codex_provider_profile
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
fn invoke_list_claude_providers(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let output: ClaudeProviderListDto = state.list_claude_providers.execute().into();
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_save_claude_provider_profile(
|
||||
args: &Value,
|
||||
state: &BackendCore,
|
||||
) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<SaveClaudeProviderProfileRequestDto>(
|
||||
"save_claude_provider_profile",
|
||||
args,
|
||||
)?;
|
||||
let output = state
|
||||
.save_claude_provider_profile
|
||||
.execute(request.into())
|
||||
.await
|
||||
.map(ProfileDto::from)
|
||||
.map_err(ErrorDto::from)?;
|
||||
serde_json::to_value(output).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_delete_profile(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let profile_id = string_arg(args, "profileId", "delete_profile")?;
|
||||
let input = backend::dto::parse_delete_profile(profile_id)?;
|
||||
@ -7625,6 +7705,12 @@ mod tests {
|
||||
"detect_profiles",
|
||||
"list_profiles",
|
||||
"save_profile",
|
||||
"list_opencode_providers",
|
||||
"save_opencode_provider_profile",
|
||||
"list_codex_providers",
|
||||
"save_codex_provider_profile",
|
||||
"list_claude_providers",
|
||||
"save_claude_provider_profile",
|
||||
"delete_profile",
|
||||
"configure_profiles",
|
||||
"clone_opencode_profile_from_seed",
|
||||
|
||||
@ -15,6 +15,8 @@ import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexProviderCatalogEntry,
|
||||
EffectivePermissions,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
@ -65,6 +67,8 @@ import type {
|
||||
PermissionGateway,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
TemplateGateway,
|
||||
@ -202,6 +206,37 @@ export class HttpProfileGateway implements ProfileGateway {
|
||||
},
|
||||
});
|
||||
}
|
||||
listCodexProviders(): Promise<CodexProviderCatalogEntry[]> {
|
||||
return this.http.invoke<CodexProviderCatalogEntry[]>("list_codex_providers");
|
||||
}
|
||||
saveCodexProviderProfile(
|
||||
input: SaveCodexProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
return this.http.invoke<AgentProfile>("save_codex_provider_profile", {
|
||||
request: {
|
||||
profile: input.profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
},
|
||||
});
|
||||
}
|
||||
listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]> {
|
||||
return this.http.invoke<ClaudeProviderCatalogEntry[]>("list_claude_providers");
|
||||
}
|
||||
saveClaudeProviderProfile(
|
||||
input: SaveClaudeProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
return this.http.invoke<AgentProfile>("save_claude_provider_profile", {
|
||||
request: {
|
||||
profile: input.profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpModelServerGateway implements ModelServerGateway {
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
AgentDrift,
|
||||
AppExitWorkGuardState,
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexProviderCatalogEntry,
|
||||
DiagnosticWarning,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
@ -103,6 +105,8 @@ import type {
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
@ -1250,6 +1254,7 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: "claude --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "claude",
|
||||
},
|
||||
{
|
||||
id: "mock-codex",
|
||||
@ -1259,6 +1264,7 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: "codex --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "codex",
|
||||
},
|
||||
{
|
||||
id: "mock-opencode",
|
||||
@ -1291,6 +1297,25 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Static mock catalogue mirroring the backend Codex provider list. */
|
||||
const MOCK_CODEX_PROVIDERS: CodexProviderCatalogEntry[] = [
|
||||
{
|
||||
providerId: "openai",
|
||||
displayName: "OpenAI",
|
||||
models: ["gpt-5", "gpt-5-mini", "gpt-5-codex", "o3"],
|
||||
customSupported: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Static mock catalogue mirroring the backend Claude provider list. */
|
||||
const MOCK_CLAUDE_PROVIDERS: ClaudeProviderCatalogEntry[] = [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
models: ["claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-3-5"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
|
||||
* the wizard can be driven and tested fully offline. By default it reports the
|
||||
@ -1386,6 +1411,53 @@ export class MockProfileGateway implements ProfileGateway {
|
||||
this.configured = true;
|
||||
return structuredClone(saved);
|
||||
}
|
||||
|
||||
async listCodexProviders(): Promise<CodexProviderCatalogEntry[]> {
|
||||
return structuredClone(MOCK_CODEX_PROVIDERS);
|
||||
}
|
||||
|
||||
async saveCodexProviderProfile(
|
||||
input: SaveCodexProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
const saved: AgentProfile = {
|
||||
...structuredClone(input.profile),
|
||||
codexProvider: {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKeyRef:
|
||||
input.profile.codexProvider?.apiKeyRef ?? `mock-secret-${input.profile.id}`,
|
||||
custom: input.custom,
|
||||
},
|
||||
};
|
||||
const i = this.profiles.findIndex((p) => p.id === saved.id);
|
||||
if (i >= 0) this.profiles[i] = saved;
|
||||
else this.profiles.push(saved);
|
||||
this.configured = true;
|
||||
return structuredClone(saved);
|
||||
}
|
||||
|
||||
async listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]> {
|
||||
return structuredClone(MOCK_CLAUDE_PROVIDERS);
|
||||
}
|
||||
|
||||
async saveClaudeProviderProfile(
|
||||
input: SaveClaudeProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
const saved: AgentProfile = {
|
||||
...structuredClone(input.profile),
|
||||
claudeProvider: {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKeyRef:
|
||||
input.profile.claudeProvider?.apiKeyRef ?? `mock-secret-${input.profile.id}`,
|
||||
},
|
||||
};
|
||||
const i = this.profiles.findIndex((p) => p.id === saved.id);
|
||||
if (i >= 0) this.profiles[i] = saved;
|
||||
else this.profiles.push(saved);
|
||||
this.configured = true;
|
||||
return structuredClone(saved);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,6 +10,8 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexProviderCatalogEntry,
|
||||
FirstRunState,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileAvailability,
|
||||
@ -17,6 +19,8 @@ import type {
|
||||
import type {
|
||||
CloneOpenCodeProfileFromSeedInput,
|
||||
ProfileGateway,
|
||||
SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
} from "@/ports";
|
||||
|
||||
@ -78,4 +82,39 @@ export class TauriProfileGateway implements ProfileGateway {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listCodexProviders(): Promise<CodexProviderCatalogEntry[]> {
|
||||
return invoke<CodexProviderCatalogEntry[]>("list_codex_providers");
|
||||
}
|
||||
|
||||
saveCodexProviderProfile(
|
||||
input: SaveCodexProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
return invoke<AgentProfile>("save_codex_provider_profile", {
|
||||
request: {
|
||||
profile: input.profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]> {
|
||||
return invoke<ClaudeProviderCatalogEntry[]>("list_claude_providers");
|
||||
}
|
||||
|
||||
saveClaudeProviderProfile(
|
||||
input: SaveClaudeProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
return invoke<AgentProfile>("save_claude_provider_profile", {
|
||||
request: {
|
||||
profile: input.profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1068,6 +1068,36 @@ export interface OpenCodeProviderConfig {
|
||||
custom?: CustomProviderConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a Codex profile backed by a provider/model pair (ticket
|
||||
* #99). `apiKeyRef` is an opaque backend SecretStore reference; the literal
|
||||
* secret is only sent through {@link ProfileGateway.saveCodexProviderProfile}.
|
||||
*/
|
||||
export interface CodexProviderConfig {
|
||||
/** Provider id used as Codex's `model_provider` (e.g. `"openai"`). */
|
||||
providerId: string;
|
||||
/** Model name written into Codex's isolated config. */
|
||||
model: string;
|
||||
/** Opaque reference to the sealed API key; never the literal key. */
|
||||
apiKeyRef: string;
|
||||
/** Optional custom OpenAI-compatible endpoint for this Codex provider. */
|
||||
custom?: CodexCustomProviderConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a Claude profile backed by a provider/model pair (ticket
|
||||
* #99). `apiKeyRef` is an opaque backend SecretStore reference; the literal
|
||||
* secret is only sent through {@link ProfileGateway.saveClaudeProviderProfile}.
|
||||
*/
|
||||
export interface ClaudeProviderConfig {
|
||||
/** Provider id. V1 backend exposes `"anthropic"`. */
|
||||
providerId: string;
|
||||
/** Model name written into Claude's isolated settings. */
|
||||
model: string;
|
||||
/** Opaque reference to the sealed API key; never the literal key. */
|
||||
apiKeyRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config for a custom OpenCode provider (mirror of the backend
|
||||
* `CustomProviderConfig`, camelCase wire format), carried by
|
||||
@ -1082,6 +1112,18 @@ export interface CustomProviderConfig {
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config for a custom Codex provider (mirror of the backend
|
||||
* `CodexCustomProviderConfig`, camelCase wire format), carried by
|
||||
* {@link CodexProviderConfig.custom}.
|
||||
*/
|
||||
export interface CodexCustomProviderConfig {
|
||||
/** Base URL of the OpenAI-compatible endpoint. */
|
||||
baseUrl: string;
|
||||
/** Optional display label written into Codex's provider table. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of the static OpenCode cloud-provider catalogue (mirror of the
|
||||
* backend `OpenCodeProviderDto`), returned by
|
||||
@ -1096,6 +1138,28 @@ export interface OpenCodeProviderCatalogEntry {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/** One entry of the static Codex provider catalogue (ticket #99). */
|
||||
export interface CodexProviderCatalogEntry {
|
||||
/** Provider id used as Codex's `model_provider`. */
|
||||
providerId: string;
|
||||
/** Human-readable label for the picker UI. */
|
||||
displayName: string;
|
||||
/** Model names this provider serves, offered for selection. */
|
||||
models: string[];
|
||||
/** Whether this provider supports a custom endpoint in the UI. */
|
||||
customSupported: boolean;
|
||||
}
|
||||
|
||||
/** One entry of the static Claude provider catalogue (ticket #99). */
|
||||
export interface ClaudeProviderCatalogEntry {
|
||||
/** Provider id. V1 backend exposes `"anthropic"`. */
|
||||
providerId: string;
|
||||
/** Human-readable label for the picker UI. */
|
||||
displayName: string;
|
||||
/** Model names this provider serves, offered for selection. */
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||
* UUID string; `detect` is the optional detection command line.
|
||||
@ -1132,6 +1196,10 @@ export interface AgentProfile {
|
||||
* both.
|
||||
*/
|
||||
opencodeProvider?: OpenCodeProviderConfig;
|
||||
/** Codex provider/model config (ticket #99). */
|
||||
codexProvider?: CodexProviderConfig;
|
||||
/** Claude provider/model config (ticket #99). */
|
||||
claudeProvider?: ClaudeProviderConfig;
|
||||
}
|
||||
|
||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||
|
||||
@ -183,6 +183,131 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — Codex/Claude provider configuration (ticket #99)", () => {
|
||||
it("saves a Codex provider profile and clears the literal key", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
|
||||
|
||||
const row = within(
|
||||
screen.getByLabelText("use OpenAI Codex CLI").closest("li")!,
|
||||
);
|
||||
const providerSelect = await row.findByLabelText("OpenAI Codex CLI provider");
|
||||
const modelSelect = row.getByLabelText(
|
||||
"OpenAI Codex CLI model",
|
||||
) as HTMLSelectElement;
|
||||
expect(modelSelect.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(providerSelect, { target: { value: "openai" } });
|
||||
expect(modelSelect.value).toBe("");
|
||||
expect(modelSelect.disabled).toBe(false);
|
||||
fireEvent.change(modelSelect, { target: { value: "gpt-5-codex" } });
|
||||
fireEvent.change(row.getByLabelText("OpenAI Codex CLI api key"), {
|
||||
target: { value: "sk-codex-secret" },
|
||||
});
|
||||
|
||||
fireEvent.click(row.getByRole("button", { name: "Enregistrer Codex" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const codex = saved.find((p) => p.command === "codex");
|
||||
expect(codex?.codexProvider).toEqual({
|
||||
providerId: "openai",
|
||||
model: "gpt-5-codex",
|
||||
apiKeyRef: "mock-secret-mock-codex",
|
||||
custom: undefined,
|
||||
});
|
||||
expect(JSON.stringify(codex)).not.toContain("sk-codex-secret");
|
||||
});
|
||||
expect((row.getByLabelText("OpenAI Codex CLI api key") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("saves a Claude provider profile without exposing the literal key", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
const claudeToggle = screen.getByLabelText(
|
||||
"use Claude Code",
|
||||
) as HTMLInputElement;
|
||||
if (!claudeToggle.checked) fireEvent.click(claudeToggle);
|
||||
|
||||
const row = within(claudeToggle.closest("li")!);
|
||||
fireEvent.change(await row.findByLabelText("Claude Code provider"), {
|
||||
target: { value: "anthropic" },
|
||||
});
|
||||
fireEvent.change(row.getByLabelText("Claude Code model"), {
|
||||
target: { value: "claude-sonnet-4-5" },
|
||||
});
|
||||
const apiKey = row.getByLabelText("Claude Code api key") as HTMLInputElement;
|
||||
fireEvent.change(apiKey, { target: { value: "sk-claude-secret" } });
|
||||
|
||||
fireEvent.click(row.getByRole("button", { name: "Enregistrer Claude" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const claude = saved.find((p) => p.command === "claude");
|
||||
expect(claude?.claudeProvider).toEqual({
|
||||
providerId: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
apiKeyRef: "mock-secret-mock-claude",
|
||||
});
|
||||
expect(JSON.stringify(claude)).not.toContain("sk-claude-secret");
|
||||
});
|
||||
expect(apiKey.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps an existing Codex SecretRef when editing provider/model", async () => {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
id: "codex-existing",
|
||||
name: "Codex configured",
|
||||
command: "codex",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: "codex --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "codex",
|
||||
codexProvider: {
|
||||
providerId: "openai",
|
||||
model: "gpt-5-mini",
|
||||
apiKeyRef: "existing-secret-ref",
|
||||
},
|
||||
},
|
||||
]);
|
||||
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 Codex configured").closest("li")!,
|
||||
);
|
||||
expect((row.getByLabelText("Codex configured api key") as HTMLInputElement).value).toBe("");
|
||||
fireEvent.change(row.getByLabelText("Codex configured model"), {
|
||||
target: { value: "gpt-5" },
|
||||
});
|
||||
fireEvent.change(row.getByLabelText("Codex configured api key"), {
|
||||
target: { value: "sk-new-secret" },
|
||||
});
|
||||
fireEvent.click(row.getByRole("button", { name: "Enregistrer Codex" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const [saved] = await profile.listProfiles();
|
||||
expect(saved.codexProvider?.model).toBe("gpt-5");
|
||||
expect(saved.codexProvider?.apiKeyRef).toBe("existing-secret-ref");
|
||||
expect(JSON.stringify(saved)).not.toContain("sk-new-secret");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
const OPENCODE = "OpenCode + llama.cpp";
|
||||
|
||||
|
||||
@ -19,6 +19,9 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexCustomProviderConfig,
|
||||
CodexProviderCatalogEntry,
|
||||
GatewayError,
|
||||
HttpChatConfig,
|
||||
LocalModelServerConfig,
|
||||
@ -61,6 +64,20 @@ interface OpenCodeProviderCatalog {
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
interface CodexProviderCatalog {
|
||||
providers: CodexProviderCatalogEntry[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
interface ClaudeProviderCatalog {
|
||||
providers: ClaudeProviderCatalogEntry[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the static OpenCode cloud-provider catalogue once for the whole
|
||||
* wizard (every Cloud row shares it), so the provider/model pickers can be
|
||||
@ -94,6 +111,62 @@ function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
|
||||
return { providers, loading, error, reload: () => void load() };
|
||||
}
|
||||
|
||||
function useCodexProviderCatalog(): CodexProviderCatalog {
|
||||
const { profile } = useGateways();
|
||||
const [providers, setProviders] = useState<CodexProviderCatalogEntry[] | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProviders(await profile.listCodexProviders());
|
||||
} catch (e) {
|
||||
setProviders(null);
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { providers, loading, error, reload: () => void load() };
|
||||
}
|
||||
|
||||
function useClaudeProviderCatalog(): ClaudeProviderCatalog {
|
||||
const { profile } = useGateways();
|
||||
const [providers, setProviders] = useState<ClaudeProviderCatalogEntry[] | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProviders(await profile.listClaudeProviders());
|
||||
} catch (e) {
|
||||
setProviders(null);
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { providers, loading, error, reload: () => void load() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the wizard when it is the first run. Calls `onDone` once the user
|
||||
* finishes (so the host can drop the wizard and show the normal UI). Returns
|
||||
@ -116,6 +189,8 @@ export function FirstRunWizard({
|
||||
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
|
||||
const modelServers = useModelServers();
|
||||
const providerCatalog = useOpenCodeProviderCatalog();
|
||||
const codexProviderCatalog = useCodexProviderCatalog();
|
||||
const claudeProviderCatalog = useClaudeProviderCatalog();
|
||||
|
||||
if (vm.isFirstRun === null) return null;
|
||||
if (!forceOpen && vm.isFirstRun === false) return null;
|
||||
@ -184,6 +259,8 @@ export function FirstRunWizard({
|
||||
entry={entry}
|
||||
servers={modelServers.servers}
|
||||
providerCatalog={providerCatalog}
|
||||
codexProviderCatalog={codexProviderCatalog}
|
||||
claudeProviderCatalog={claudeProviderCatalog}
|
||||
onToggle={() => vm.toggle(entry.profile.id)}
|
||||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||||
onRemove={() => vm.remove(entry.profile.id)}
|
||||
@ -215,6 +292,8 @@ function ProfileRow({
|
||||
entry,
|
||||
servers,
|
||||
providerCatalog,
|
||||
codexProviderCatalog,
|
||||
claudeProviderCatalog,
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
@ -225,6 +304,8 @@ function ProfileRow({
|
||||
servers: LocalModelServerConfig[];
|
||||
/** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */
|
||||
providerCatalog: OpenCodeProviderCatalog;
|
||||
codexProviderCatalog: CodexProviderCatalog;
|
||||
claudeProviderCatalog: ClaudeProviderCatalog;
|
||||
onToggle: () => void;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
onRemove: () => void;
|
||||
@ -329,6 +410,24 @@ function ProfileRow({
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profile.structuredAdapter === "codex" &&
|
||||
(selected || profile.codexProvider) && (
|
||||
<CodexProviderFields
|
||||
profile={profile}
|
||||
catalog={codexProviderCatalog}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profile.structuredAdapter === "claude" &&
|
||||
(selected || profile.claudeProvider) && (
|
||||
<ClaudeProviderFields
|
||||
profile={profile}
|
||||
catalog={claudeProviderCatalog}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@ -794,6 +893,433 @@ function OpenCodeProviderFields({
|
||||
);
|
||||
}
|
||||
|
||||
type SimpleProviderCatalogEntry =
|
||||
| CodexProviderCatalogEntry
|
||||
| ClaudeProviderCatalogEntry;
|
||||
|
||||
interface ProviderModelSecretFieldsProps {
|
||||
engine: "Codex" | "Claude";
|
||||
profile: AgentProfile;
|
||||
catalog: {
|
||||
providers: SimpleProviderCatalogEntry[] | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
};
|
||||
existing:
|
||||
| AgentProfile["codexProvider"]
|
||||
| AgentProfile["claudeProvider"]
|
||||
| undefined;
|
||||
customSupported: boolean;
|
||||
saveProfile: (input: {
|
||||
providerId: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
custom?: CodexCustomProviderConfig;
|
||||
}) => Promise<AgentProfile>;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}
|
||||
|
||||
function ProviderModelSecretFields({
|
||||
engine,
|
||||
profile,
|
||||
catalog,
|
||||
existing,
|
||||
customSupported,
|
||||
saveProfile,
|
||||
onChange,
|
||||
}: ProviderModelSecretFieldsProps) {
|
||||
const [mode, setMode] = useState<"catalog" | "custom">(
|
||||
existing && "custom" in existing && existing.custom ? "custom" : "catalog",
|
||||
);
|
||||
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
||||
const [model, setModel] = useState(existing?.model ?? "");
|
||||
const [providerFilter, setProviderFilter] = useState("");
|
||||
const existingCustom =
|
||||
existing && "custom" in existing ? existing.custom : undefined;
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState(existingCustom?.baseUrl ?? "");
|
||||
const [customDisplayName, setCustomDisplayName] = useState(
|
||||
existingCustom?.displayName ?? "",
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
|
||||
|
||||
const isEditing = existing !== undefined;
|
||||
const catalogReady = catalog.providers !== null && !catalog.loading;
|
||||
const models =
|
||||
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
||||
const filteredProviders = (catalog.providers ?? []).filter((p) => {
|
||||
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 ||
|
||||
apiKey.length === 0 ||
|
||||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
|
||||
|
||||
async function save() {
|
||||
const errors: CloudFieldErrors = {};
|
||||
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" && 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;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const saved = await saveProfile({
|
||||
providerId: providerId.trim(),
|
||||
model: model.trim(),
|
||||
apiKey,
|
||||
...(mode === "custom"
|
||||
? {
|
||||
custom: {
|
||||
baseUrl: customBaseUrl.trim(),
|
||||
displayName:
|
||||
customDisplayName.trim().length > 0
|
||||
? customDisplayName.trim()
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onChange(saved);
|
||||
setApiKey("");
|
||||
} catch (e) {
|
||||
setSaveError(describeError(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||
<legend className="px-1 text-xs font-medium text-muted">
|
||||
Provider cloud ({engine})
|
||||
</legend>
|
||||
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{catalog.loading && (
|
||||
<p className="text-xs text-faint">Chargement des providers…</p>
|
||||
)}
|
||||
{catalog.error && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
Impossible de charger la liste des providers cloud.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
|
||||
Réessayer
|
||||
</Button>
|
||||
</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) => {
|
||||
const v = e.target.value;
|
||||
if (v === CUSTOM_PROVIDER_VALUE) {
|
||||
setMode("custom");
|
||||
setProviderId("");
|
||||
} else {
|
||||
setProviderId(v);
|
||||
}
|
||||
setModel("");
|
||||
setFieldErrors((prev) => ({
|
||||
...prev,
|
||||
providerId: undefined,
|
||||
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.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>
|
||||
))}
|
||||
{customSupported && (
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "custom" && customSupported && (
|
||||
<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>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 provider (optionnel)</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} custom display name`}
|
||||
value={customDisplayName}
|
||||
placeholder="ex. Mon provider"
|
||||
onChange={(e) => setCustomDisplayName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Clé API</Caption>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
aria-label={`${profile.name} api key`}
|
||||
type={showKey ? "text" : "password"}
|
||||
value={apiKey}
|
||||
placeholder={
|
||||
isEditing
|
||||
? "Ressaisissez la clé API pour confirmer l'enregistrement"
|
||||
: "ex. sk-…"
|
||||
}
|
||||
invalid={Boolean(fieldErrors.apiKey)}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label="afficher/masquer la clé API"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
>
|
||||
{showKey ? "🙈" : "👁"}
|
||||
</IconButton>
|
||||
</div>
|
||||
{fieldErrors.apiKey && (
|
||||
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
|
||||
)}
|
||||
<small className="text-xs text-faint">
|
||||
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
|
||||
chiffrée localement.
|
||||
</small>
|
||||
{isEditing && (
|
||||
<small className="text-xs text-muted">
|
||||
Le profil conserve sa référence de secret existante ; la clé n'est
|
||||
jamais réaffichée côté UI.
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
aria-label={`Enregistrer ${engine}`}
|
||||
loading={saving}
|
||||
disabled={saveDisabled}
|
||||
onClick={() => void save()}
|
||||
className="w-fit"
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexProviderFields({
|
||||
profile,
|
||||
catalog,
|
||||
onChange,
|
||||
}: {
|
||||
profile: AgentProfile;
|
||||
catalog: CodexProviderCatalog;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { profile: profileGateway } = useGateways();
|
||||
return (
|
||||
<ProviderModelSecretFields
|
||||
engine="Codex"
|
||||
profile={profile}
|
||||
catalog={catalog}
|
||||
existing={profile.codexProvider}
|
||||
customSupported={catalog.providers?.some((p) => p.customSupported) ?? false}
|
||||
saveProfile={(input) =>
|
||||
profileGateway.saveCodexProviderProfile({
|
||||
profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
})
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaudeProviderFields({
|
||||
profile,
|
||||
catalog,
|
||||
onChange,
|
||||
}: {
|
||||
profile: AgentProfile;
|
||||
catalog: ClaudeProviderCatalog;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { profile: profileGateway } = useGateways();
|
||||
return (
|
||||
<ProviderModelSecretFields
|
||||
engine="Claude"
|
||||
profile={profile}
|
||||
catalog={catalog}
|
||||
existing={profile.claudeProvider}
|
||||
customSupported={false}
|
||||
saveProfile={(input) =>
|
||||
profileGateway.saveClaudeProviderProfile({
|
||||
profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
})
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenCode + llama.cpp config section: the base URL of the local
|
||||
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key
|
||||
|
||||
@ -13,6 +13,9 @@ import type {
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
AppExitWorkGuardState,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexCustomProviderConfig,
|
||||
CodexProviderCatalogEntry,
|
||||
CustomProviderConfig,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
@ -691,6 +694,24 @@ export interface ProfileGateway {
|
||||
saveOpenCodeProviderProfile(
|
||||
input: SaveOpenCodeProviderProfileInput,
|
||||
): Promise<AgentProfile>;
|
||||
/** Static catalogue of Codex providers/models (ticket #99). */
|
||||
listCodexProviders(): Promise<CodexProviderCatalogEntry[]>;
|
||||
/**
|
||||
* Creates or replaces (by id) a Codex profile with provider/model/secret
|
||||
* config. The literal API key is sealed backend-side and never returned.
|
||||
*/
|
||||
saveCodexProviderProfile(
|
||||
input: SaveCodexProviderProfileInput,
|
||||
): Promise<AgentProfile>;
|
||||
/** Static catalogue of Claude providers/models (ticket #99). */
|
||||
listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]>;
|
||||
/**
|
||||
* Creates or replaces (by id) a Claude profile with provider/model/secret
|
||||
* config. The literal API key is sealed backend-side and never returned.
|
||||
*/
|
||||
saveClaudeProviderProfile(
|
||||
input: SaveClaudeProviderProfileInput,
|
||||
): Promise<AgentProfile>;
|
||||
}
|
||||
|
||||
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
|
||||
@ -719,6 +740,32 @@ export interface SaveOpenCodeProviderProfileInput {
|
||||
custom?: CustomProviderConfig;
|
||||
}
|
||||
|
||||
/** Input for {@link ProfileGateway.saveCodexProviderProfile}. */
|
||||
export interface SaveCodexProviderProfileInput {
|
||||
/** The profile to create or replace (by id). */
|
||||
profile: AgentProfile;
|
||||
/** Provider id used as Codex's `model_provider`. */
|
||||
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 endpoint config for a Codex provider. */
|
||||
custom?: CodexCustomProviderConfig;
|
||||
}
|
||||
|
||||
/** Input for {@link ProfileGateway.saveClaudeProviderProfile}. */
|
||||
export interface SaveClaudeProviderProfileInput {
|
||||
/** The profile to create or replace (by id). */
|
||||
profile: AgentProfile;
|
||||
/** Provider id. V1 backend exposes `"anthropic"`. */
|
||||
providerId: string;
|
||||
/** Model name served by this provider. */
|
||||
model: string;
|
||||
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local model servers (F35). CRUD over the global registry of declared
|
||||
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||
|
||||
Reference in New Issue
Block a user