From c181b43d04b17b1359e71f0b43a48a6648b1f1fd Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 23 Jul 2026 08:18:02 +0200 Subject: [PATCH] feat(frontend): support des providers OpenCode cloud (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'UI de configuration des providers OpenCode cloud dans le wizard first-run (sélection, édition, sauvegarde, suppression), le domaine et les ports associés, ainsi que les adapters HTTP/mock correspondants. Build + 952 tests verts, comportements clés vérifiés en exécution réelle, y compris un test de couverture ajouté pour le parcours d'édition. Co-Authored-By: Claude Opus 4.8 --- .../adapters/http/requestResponseGateways.ts | 17 + frontend/src/adapters/mock/index.ts | 40 ++ frontend/src/adapters/profile.ts | 30 +- frontend/src/domain/index.ts | 38 ++ .../first-run/FirstRunWizard.test.tsx | 147 ++++++++ .../src/features/first-run/FirstRunWizard.tsx | 347 +++++++++++++++++- frontend/src/features/first-run/profile.ts | 8 +- frontend/src/ports/index.ts | 28 ++ 8 files changed, 649 insertions(+), 6 deletions(-) diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index cb9f3d5..6d2b0ec 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -34,6 +34,7 @@ import type { MemoryType, McpToolPolicy, ModelServerCommandPreview, + OpenCodeProviderCatalogEntry, PermissionSet, Project, ProjectMcpToolPermissions, @@ -61,6 +62,7 @@ import type { PermissionGateway, ProfileGateway, ProjectGateway, + SaveOpenCodeProviderProfileInput, SkillGateway, TemplateGateway, WorkStateGateway, @@ -181,6 +183,21 @@ export class HttpProfileGateway implements ProfileGateway { request: { name: input.name, opencode: input.opencode }, }); } + listOpenCodeProviders(): Promise { + return this.http.invoke("list_opencode_providers"); + } + saveOpenCodeProviderProfile( + input: SaveOpenCodeProviderProfileInput, + ): Promise { + return this.http.invoke("save_opencode_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 5a7fe4b..7e55dac 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -34,6 +34,7 @@ import type { MemoryType, McpToolCatalogue, McpToolPolicy, + OpenCodeProviderCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -99,6 +100,7 @@ import type { ReattachResult, RemoteGateway, ReviewPluginPackageInput, + SaveOpenCodeProviderProfileInput, SkillGateway, StoppedLiveAgent, SystemGateway, @@ -1272,6 +1274,20 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ }, ]; +/** Static mock catalogue mirroring the backend OpenCode cloud-provider list. */ +const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [ + { + providerId: "anthropic", + displayName: "Anthropic", + models: ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"], + }, + { + providerId: "openrouter", + displayName: "OpenRouter", + models: ["openrouter/auto", "qwen/qwen3-coder"], + }, +]; + /** * 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 @@ -1342,6 +1358,30 @@ export class MockProfileGateway implements ProfileGateway { opencode: input.opencode ?? seed.opencode, }); } + + async listOpenCodeProviders(): Promise { + return structuredClone(MOCK_OPENCODE_PROVIDERS); + } + + async saveOpenCodeProviderProfile( + input: SaveOpenCodeProviderProfileInput, + ): Promise { + const saved: AgentProfile = { + ...structuredClone(input.profile), + opencode: undefined, + opencodeProvider: { + providerId: input.providerId, + model: input.model, + // The mock never seals a real secret; the ref is opaque either way. + 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 f15f038..3e0ac3e 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -8,8 +8,17 @@ import { invoke } from "@tauri-apps/api/core"; -import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain"; -import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports"; +import type { + AgentProfile, + FirstRunState, + OpenCodeProviderCatalogEntry, + ProfileAvailability, +} from "@/domain"; +import type { + CloneOpenCodeProfileFromSeedInput, + ProfileGateway, + SaveOpenCodeProviderProfileInput, +} from "@/ports"; export class TauriProfileGateway implements ProfileGateway { firstRunState(): Promise { @@ -51,4 +60,21 @@ export class TauriProfileGateway implements ProfileGateway { request: { name: input.name, opencode: input.opencode }, }); } + + listOpenCodeProviders(): Promise { + return invoke("list_opencode_providers"); + } + + saveOpenCodeProviderProfile( + input: SaveOpenCodeProviderProfileInput, + ): Promise { + return invoke("save_opencode_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 cb81204..ca10a2d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -999,6 +999,38 @@ export interface OpenCodeConfig { localModelServerId?: string; } +/** + * Configuration for an OpenCode profile backed by a **cloud** provider from the + * OpenCode registry (Anthropic, OpenRouter, …), as opposed to the custom + * `llamacpp` provider of {@link OpenCodeConfig}. Mirror of the backend + * `OpenCodeProviderConfig` (camelCase wire format). `apiKeyRef` is an opaque + * reference into the backend `SecretStore` — never the literal key, which is + * only ever sent (never read back) via + * {@link ProfileGateway.saveOpenCodeProviderProfile}. + */ +export interface OpenCodeProviderConfig { + /** Provider id in the OpenCode registry (e.g. `"anthropic"`). */ + providerId: string; + /** Model name served by this provider. */ + model: string; + /** Opaque reference to the sealed API key; never the literal key. */ + apiKeyRef: string; +} + +/** + * One entry of the static OpenCode cloud-provider catalogue (mirror of the + * backend `OpenCodeProviderDto`), returned by + * {@link ProfileGateway.listOpenCodeProviders}. + */ +export interface OpenCodeProviderCatalogEntry { + /** Provider id in the OpenCode registry (e.g. `"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. @@ -1029,6 +1061,12 @@ export interface AgentProfile { chatHttp?: HttpChatConfig; /** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */ opencode?: OpenCodeConfig; + /** + * OpenCode **cloud** provider config (ticket #92). Mutually exclusive with + * {@link opencode}: a profile is either local (`llamacpp`) or cloud, never + * both. + */ + opencodeProvider?: OpenCodeProviderConfig; } /** 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 cad4762..116555f 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -11,6 +11,7 @@ import { screen, waitFor, fireEvent, + within, } from "@testing-library/react"; import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock"; @@ -283,6 +284,152 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => { }); }); +describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => { + const OPENCODE = "OpenCode + llama.cpp"; + + it("defaults to Local, and switching to Cloud swaps the sub-form", async () => { + renderWizard(); + await waitForLoaded(); + + expect( + screen.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute( + "aria-checked", + ), + ).toBe("true"); + expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy(); + + fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); + + expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull(); + await screen.findByLabelText(`${OPENCODE} provider`); + }); + + it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => { + const profile = new MockProfileGateway(); + await profile.configureProfiles([ + { + id: "cfg-oc-cloud-1", + name: "Claude via OpenCode", + command: "opencode", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "opencode --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "openCode", + opencodeProvider: { + providerId: "anthropic", + model: "claude-sonnet-5", + apiKeyRef: "secret-ref-1", + }, + }, + ]); + + const gateways = { + profile, + modelServer: new MockModelServerGateway(), + } as unknown as Gateways; + render( + + + , + ); + await waitForLoaded(); + + // Scope to this row: the still-unconfigured "OpenCode + llama.cpp" + // reference also renders (edit mode dedups by id only), with its own + // Local/Cloud segmented control. + const row = within( + screen.getByLabelText("use Claude via OpenCode").closest("li")!, + ); + expect( + row.getByRole("radio", { name: "Provider cloud" }).getAttribute( + "aria-checked", + ), + ).toBe("true"); + expect( + row.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute( + "aria-checked", + ), + ).toBe("false"); + expect( + (row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement) + .value, + ).toBe(""); + }); + + it("cascades provider ➜ model and disables Save until the API key is filled", async () => { + renderWizard(); + await waitForLoaded(); + fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); + + const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`); + const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement; + expect(modelSelect.disabled).toBe(true); + + fireEvent.change(providerSelect, { target: { value: "anthropic" } }); + expect(modelSelect.disabled).toBe(false); + + const saveButton = screen.getByRole("button", { + name: "Enregistrer", + }) as HTMLButtonElement; + expect(saveButton.disabled).toBe(true); + + fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } }); + expect(saveButton.disabled).toBe(true); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-ant-secret" }, + }); + expect(saveButton.disabled).toBe(false); + }); + + it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); + + fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), { + target: { value: "anthropic" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "claude-sonnet-5" }, + }); + const apiKeyInput = screen.getByLabelText( + `${OPENCODE} api key`, + ) as HTMLInputElement; + fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } }); + + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.opencodeProvider?.providerId).toBe("anthropic"); + expect(opencode?.opencodeProvider?.model).toBe("claude-sonnet-5"); + expect(opencode?.opencode).toBeUndefined(); + }); + // The key is never kept around client-side once saved. + expect(apiKeyInput.value).toBe(""); + }); + + it("shows submit-time validation messages when provider/model/key are missing", async () => { + renderWizard(); + await waitForLoaded(); + fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); + await screen.findByLabelText(`${OPENCODE} provider`); + + // Save stays disabled with no key, so drive validation via a filled key but + // no provider/model to see the field-level messages fire on submit. + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-ant-secret" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy(); + expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy(); + }); +}); + describe("FirstRunWizard — several local OpenCode profiles (F36)", () => { const OPENCODE = "OpenCode + llama.cpp"; const CLONE1 = `${OPENCODE} (copy 1)`; diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 986e743..5de1c05 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,12 +15,17 @@ * `./profile`. */ +import { useCallback, useEffect, useState } from "react"; + import type { AgentProfile, + GatewayError, HttpChatConfig, LocalModelServerConfig, OpenCodeConfig, + OpenCodeProviderCatalogEntry, } from "@/domain"; +import { useGateways } from "@/app/di"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { ModelServersPanel, @@ -41,6 +46,54 @@ function Caption({ children }: { children: React.ReactNode }) { return {children}; } +function describeError(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as GatewayError).message); + } + return String(e); +} + +/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */ +interface OpenCodeProviderCatalog { + providers: OpenCodeProviderCatalogEntry[] | 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 + * populated. Exposes a `reload` for the blocking "Réessayer" state. + */ +function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog { + 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.listOpenCodeProviders()); + } 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 @@ -62,6 +115,7 @@ export function FirstRunWizard({ // which pre-loads and pre-selects the already-configured profiles. const vm = useFirstRun(forceOpen ? "edit" : "firstRun"); const modelServers = useModelServers(); + const providerCatalog = useOpenCodeProviderCatalog(); if (vm.isFirstRun === null) return null; if (!forceOpen && vm.isFirstRun === false) return null; @@ -129,6 +183,7 @@ export function FirstRunWizard({ key={entry.profile.id} entry={entry} servers={modelServers.servers} + providerCatalog={providerCatalog} onToggle={() => vm.toggle(entry.profile.id)} onChange={(p) => vm.updateProfile(entry.profile.id, p)} onRemove={() => vm.remove(entry.profile.id)} @@ -159,6 +214,7 @@ export function FirstRunWizard({ function ProfileRow({ entry, servers, + providerCatalog, onToggle, onChange, onRemove, @@ -167,6 +223,8 @@ function ProfileRow({ entry: WizardEntry; /** Declared local model servers (F35.2), for the OpenCode binding dropdown. */ servers: LocalModelServerConfig[]; + /** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */ + providerCatalog: OpenCodeProviderCatalog; onToggle: () => void; onChange: (p: AgentProfile) => void; onRemove: () => void; @@ -263,6 +321,77 @@ function ProfileRow({ )} {profile.structuredAdapter === "openCode" && ( + + )} + + ); +} + +/** + * Segmented control (F — ticket #92) choosing whether an OpenCode profile runs + * against the local `llama.cpp` endpoint or a cloud provider from the OpenCode + * registry, and renders the matching sub-form. The two sub-forms never overlap; + * switching segments keeps the inactive one's draft in memory (component-local + * state) without touching `profile` until it is actually submitted. + */ +function OpenCodeModeFields({ + profile, + errors, + servers, + providerCatalog, + onChange, +}: { + profile: AgentProfile; + errors: ProfileErrors; + servers: LocalModelServerConfig[]; + providerCatalog: OpenCodeProviderCatalog; + onChange: (p: AgentProfile) => void; +}) { + const [mode, setMode] = useState<"local" | "cloud">( + profile.opencodeProvider ? "cloud" : "local", + ); + + return ( +
+
+ {( + [ + { id: "local", label: "Local (llama.cpp)" }, + { id: "cloud", label: "Provider cloud" }, + ] as const + ).map((seg) => { + const active = mode === seg.id; + return ( + + ); + })} +
+ + {mode === "local" && ( )} - + + {mode === "cloud" && ( + + )} +
+ ); +} + +/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */ +interface CloudFieldErrors { + providerId?: string; + model?: string; + apiKey?: string; +} + +/** + * The OpenCode **cloud** provider config section (ticket #92): provider ➜ + * model (cascading selects fed by the static catalogue) ➜ API key. The key is + * never pre-filled (create or edit) since the backend never returns it — it + * resigns whatever literal it receives on every save, so editing an existing + * cloud profile requires re-entering it every time (§3 of the spec). + */ +function OpenCodeProviderFields({ + profile, + catalog, + onChange, +}: { + profile: AgentProfile; + catalog: OpenCodeProviderCatalog; + onChange: (p: AgentProfile) => void; +}) { + const { profile: profileGateway } = useGateways(); + const existing = profile.opencodeProvider; + const [providerId, setProviderId] = useState(existing?.providerId ?? ""); + const [model, setModel] = useState(existing?.model ?? ""); + 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 models = + catalog.providers?.find((p) => p.providerId === providerId)?.models ?? []; + const catalogReady = catalog.providers !== null && !catalog.loading; + const saveDisabled = + saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0; + + async function save() { + const errors: CloudFieldErrors = {}; + if (providerId.length === 0) errors.providerId = "Le provider est obligatoire."; + if (model.length === 0) errors.model = "Le modèle est obligatoire."; + if (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 profileGateway.saveOpenCodeProviderProfile({ + profile, + providerId, + model, + apiKey, + }); + onChange(saved); + setApiKey(""); + } catch (e) { + setSaveError(describeError(e)); + } finally { + setSaving(false); + } + } + + return ( +
+ + Provider cloud (OpenCode) + + + {saveError && ( +

+ {saveError} +

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

Chargement des providers…

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

+ Impossible de charger la liste des providers cloud. +

+ +
+ )} + + + + + + + + +
); } diff --git a/frontend/src/features/first-run/profile.ts b/frontend/src/features/first-run/profile.ts index aacdda4..7c31361 100644 --- a/frontend/src/features/first-run/profile.ts +++ b/frontend/src/features/first-run/profile.ts @@ -144,9 +144,11 @@ export function validateProfile(p: AgentProfile): ProfileErrors { Object.assign(errors, validateHttpChatConfig(p.chatHttp)); } } - // An OpenCode profile carries its own llama.cpp endpoint config; the backend - // refuses to persist it unless base URL + model are well-formed, so mirror that. - if (p.structuredAdapter === "openCode") { + // An OpenCode profile carries its own llama.cpp endpoint config (local mode) + // or a cloud provider config (`opencodeProvider`, ticket #92) — never both, + // and the cloud sub-form owns its own submit-time validation (provider, + // model, API key), so only the local-mode shape is mirrored here. + if (p.structuredAdapter === "openCode" && !p.opencodeProvider) { if (!p.opencode) { errors.baseURL = "Base URL must start with http:// or https://."; errors.model = "Model is required."; diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 72cfa02..51c2d3f 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -35,6 +35,7 @@ import type { MemoryType, McpToolPolicy, OpenCodeConfig, + OpenCodeProviderCatalogEntry, EffectivePermissions, PairedDevice, PairingCode, @@ -671,6 +672,21 @@ export interface ProfileGateway { cloneOpenCodeProfileFromSeed( input?: CloneOpenCodeProfileFromSeedInput, ): Promise; + /** + * Static catalogue of OpenCode cloud providers (ticket #92), for the + * provider/model pickers of the Cloud sub-form. + */ + listOpenCodeProviders(): Promise; + /** + * Creates or replaces (by id) an OpenCode profile in **cloud** mode (ticket + * #92). Unlike {@link saveProfile}, this takes the literal API key: the + * backend seals it into the `SecretStore` and never returns it — the + * returned profile's `opencodeProvider` only ever carries `providerId` + + * `model` (+ the opaque `apiKeyRef`), never the literal key. + */ + saveOpenCodeProviderProfile( + input: SaveOpenCodeProviderProfileInput, + ): Promise; } /** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */ @@ -681,6 +697,18 @@ export interface CloneOpenCodeProfileFromSeedInput { opencode?: OpenCodeConfig; } +/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */ +export interface SaveOpenCodeProviderProfileInput { + /** The profile to create or replace (by id). */ + profile: AgentProfile; + /** Provider id in the OpenCode registry (e.g. `"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