From dae07d35bb634323666263d1d799cc21e04dc827 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 26 Jul 2026 11:56:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20impl=C3=A9mentation=20et=20QA=20ticket9?= =?UTF-8?q?9=20-=20agent=20model=20configuration=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json | 227 ++++++++ .ideai/tickets/99/carnet.md | 4 +- .ideai/tickets/99/issue.md | 6 +- .ideai/tickets/index.json | 4 +- crates/app-tauri/src/commands.rs | 87 ++- crates/app-tauri/src/embedded_server.rs | 60 +- crates/app-tauri/src/lib.rs | 4 + crates/application/src/agent/lifecycle.rs | 103 +++- crates/application/src/agent/mod.rs | 8 +- .../src/agent/provider_catalogue.rs | 118 ++++ crates/application/src/agent/usecases.rs | 152 ++++- crates/application/src/lib.rs | 37 +- crates/application/tests/profile_usecases.rs | 72 ++- crates/backend/src/dto.rs | 128 ++++- crates/backend/src/lib.rs | 62 ++- crates/domain/src/permission.rs | 9 + crates/domain/src/profile.rs | 192 +++++++ .../infrastructure/src/permission/claude.rs | 61 +- crates/infrastructure/src/permission/codex.rs | 135 ++++- crates/web-server/src/lib.rs | 120 +++- .../adapters/http/requestResponseGateways.ts | 35 ++ frontend/src/adapters/mock/index.ts | 72 +++ frontend/src/adapters/profile.ts | 39 ++ frontend/src/domain/index.ts | 68 +++ .../first-run/FirstRunWizard.test.tsx | 125 +++++ .../src/features/first-run/FirstRunWizard.tsx | 526 ++++++++++++++++++ frontend/src/ports/index.ts | 47 ++ 27 files changed, 2358 insertions(+), 143 deletions(-) diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index c1f2d6c..259eb4d 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -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 } ] } \ No newline at end of file diff --git a/.ideai/tickets/99/carnet.md b/.ideai/tickets/99/carnet.md index 910843c..55c1de3 100644 --- a/.ideai/tickets/99/carnet.md +++ b/.ideai/tickets/99/carnet.md @@ -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) diff --git a/.ideai/tickets/99/issue.md b/.ideai/tickets/99/issue.md index 20a4fee..018a47d 100644 --- a/.ideai/tickets/99/issue.md +++ b/.ideai/tickets/99/issue.md @@ -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 diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index c0ae266..462a4ec 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -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", diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index dd9d93d..172bf18 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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 { + 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 { + 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 { + 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. /// diff --git a/crates/app-tauri/src/embedded_server.rs b/crates/app-tauri/src/embedded_server.rs index 4397dda..b45b675 100644 --- a/crates/app-tauri/src/embedded_server.rs +++ b/crates/app-tauri/src/embedded_server.rs @@ -623,29 +623,6 @@ mod tests { use super::*; use uuid::Uuid; - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - 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"), + "IdeA", + ) + .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, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 19c8ab1..fb56a36 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 60129be..8aa64d9 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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 { + 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 { + 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, diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 9035c68..9905473 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -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, }; diff --git a/crates/application/src/agent/provider_catalogue.rs b/crates/application/src/agent/provider_catalogue.rs index 8f0a2ee..50063ac 100644 --- a/crates/application/src/agent/provider_catalogue.rs +++ b/crates/application/src/agent/provider_catalogue.rs @@ -41,6 +41,60 @@ pub struct OpenCodeProviderCatalogEntry { pub(crate) model_labels: BTreeMap, } +/// 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, + /// 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, +} + +/// Returns the static Codex provider catalogue. +#[must_use] +pub fn codex_provider_catalogue() -> Vec { + 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 { + 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 { @@ -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, +} + +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, +} + +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::*; diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index 5cfd809..91c6681 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -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, +} + +/// 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, + secret_store: Arc, + ids: Arc, +} + +impl SaveCodexProviderProfile { + /// Builds the use case from the profile store, secret store and id generator + /// ports. + #[must_use] + pub fn new( + profile_store: Arc, + secret_store: Arc, + ids: Arc, + ) -> 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 { + 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, + secret_store: Arc, + ids: Arc, +} + +impl SaveClaudeProviderProfile { + /// Builds the use case from the profile store, secret store and id generator + /// ports. + #[must_use] + pub fn new( + profile_store: Arc, + secret_store: Arc, + ids: Arc, + ) -> 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 { + 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(()) diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 851181c..549add9 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 6b242be..53d67ad 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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(); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 258065a..ef27529 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -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 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, + pub custom_supported: bool, +} + +impl From 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); + +impl From 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, +} + +impl From 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); + +impl From 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 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, +} + +impl From 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 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 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 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")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 5526b71..753458d 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -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, + /// 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, + /// 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, /// Static catalogue of OpenCode cloud providers (ticket #92, lot B3). pub list_opencode_providers: Arc, + /// Static catalogue of Codex providers (ticket #99). + pub list_codex_providers: Arc, + /// Static catalogue of Claude providers (ticket #99). + pub list_claude_providers: Arc, /// Create a new OpenCode profile instance from the canonical seed. pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. @@ -1467,7 +1477,19 @@ impl BackendCore { Arc::clone(&secret_store_port), Arc::clone(&ids) as Arc, )); + let save_codex_provider_profile = Arc::new(SaveCodexProviderProfile::new( + Arc::clone(&profile_store_port), + Arc::clone(&secret_store_port), + Arc::clone(&ids) as Arc, + )); + let save_claude_provider_profile = Arc::new(SaveClaudeProviderProfile::new( + Arc::clone(&profile_store_port), + Arc::clone(&secret_store_port), + Arc::clone(&ids) as Arc, + )); 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, @@ -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, diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index 94add72..3dd98ac 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -850,6 +850,15 @@ pub struct ProjectionContext<'a> { pub project_root: &'a str, /// Absolute isolated run dir of the agent (`.ideai/run//`). 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**. diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 5aaf921..dedf39c 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -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, +} + +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, + display_name: Option, + ) -> Result { + 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, +} + +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, + model: impl Into, + api_key_ref: crate::ports::SecretRef, + ) -> Result { + 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, + model: impl Into, + api_key_ref: crate::ports::SecretRef, + ) -> Result { + 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, + /// 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, + /// 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, /// 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( diff --git a/crates/infrastructure/src/permission/claude.rs b/crates/infrastructure/src/permission/claude.rs index ba491ed..132709f 100644 --- a/crates/infrastructure/src/permission/claude.rs +++ b/crates/infrastructure/src/permission/claude.rs @@ -34,11 +34,12 @@ impl PermissionProjector for ClaudePermissionProjector { _network: Option, 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!( diff --git a/crates/infrastructure/src/permission/codex.rs b/crates/infrastructure/src/permission/codex.rs index 0eb3c4d..71b03aa 100644 --- a/crates/infrastructure/src/permission/codex.rs +++ b/crates/infrastructure/src/permission/codex.rs @@ -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) -> ProjectedFile { +fn codex_model_and_network_file( + ctx: &ProjectionContext, + network: Option, +) -> 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 { + 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 { + 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) -> 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 --------------------- diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 0e6f82b..a63b6c8 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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 Result { + 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 { + let request = required_request::( + "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 { + 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 { + let request = required_request::( + "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 { + 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 { + let request = required_request::( + "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 { 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", diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index c2c7dfc..cc1ad51 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -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 { + return this.http.invoke("list_codex_providers"); + } + saveCodexProviderProfile( + input: SaveCodexProviderProfileInput, + ): Promise { + return this.http.invoke("save_codex_provider_profile", { + request: { + profile: input.profile, + providerId: input.providerId, + model: input.model, + apiKey: input.apiKey, + custom: input.custom, + }, + }); + } + listClaudeProviders(): Promise { + return this.http.invoke("list_claude_providers"); + } + saveClaudeProviderProfile( + input: SaveClaudeProviderProfileInput, + ): Promise { + return this.http.invoke("save_claude_provider_profile", { + request: { + profile: input.profile, + providerId: input.providerId, + model: input.model, + apiKey: input.apiKey, + }, + }); + } } export class HttpModelServerGateway implements ModelServerGateway { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 733c0c6..7123f9d 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { + return structuredClone(MOCK_CODEX_PROVIDERS); + } + + async saveCodexProviderProfile( + input: SaveCodexProviderProfileInput, + ): Promise { + 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 { + return structuredClone(MOCK_CLAUDE_PROVIDERS); + } + + async saveClaudeProviderProfile( + input: SaveClaudeProviderProfileInput, + ): Promise { + 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); + } } /** diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index 62a72f7..b13b2f5 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -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 { + return invoke("list_codex_providers"); + } + + saveCodexProviderProfile( + input: SaveCodexProviderProfileInput, + ): Promise { + return invoke("save_codex_provider_profile", { + request: { + profile: input.profile, + providerId: input.providerId, + model: input.model, + apiKey: input.apiKey, + custom: input.custom, + }, + }); + } + + listClaudeProviders(): Promise { + return invoke("list_claude_providers"); + } + + saveClaudeProviderProfile( + input: SaveClaudeProviderProfileInput, + ): Promise { + return invoke("save_claude_provider_profile", { + request: { + profile: input.profile, + providerId: input.providerId, + model: input.model, + apiKey: input.apiKey, + }, + }); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 8ef9cca..1a65f20 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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). */ diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index ce82b9c..ce1c861 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -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( + + + , + ); + 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"; diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index d56632f..518aecf 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -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( + null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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( + null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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) && ( + + )} + + {profile.structuredAdapter === "claude" && + (selected || profile.claudeProvider) && ( + + )} ); } @@ -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; + 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(null); + const [fieldErrors, setFieldErrors] = useState({}); + + 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 ( +
+ + Provider cloud ({engine}) + + + {saveError && ( +

+ {saveError} +

+ )} + + {catalog.loading && ( +

Chargement des providers…

+ )} + {catalog.error && ( +
+

+ Impossible de charger la liste des providers cloud. +

+ +
+ )} + + {mode === "catalog" && ( + <> + + + + + )} + + {mode === "custom" && customSupported && ( +
+ + Provider personnalisé + + + + + + + + + + + +
+ )} + + + + +
+ ); +} + +function CodexProviderFields({ + profile, + catalog, + onChange, +}: { + profile: AgentProfile; + catalog: CodexProviderCatalog; + onChange: (p: AgentProfile) => void; +}) { + const { profile: profileGateway } = useGateways(); + return ( + 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 ( + + 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 diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 790742c..95ae44d 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -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; + /** Static catalogue of Codex providers/models (ticket #99). */ + listCodexProviders(): Promise; + /** + * 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; + /** Static catalogue of Claude providers/models (ticket #99). */ + listClaudeProviders(): Promise; + /** + * 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; } /** 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