From 1784027f5de41b97063adcfd79fb420a5a1bec3e Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 23 Jul 2026 12:44:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20provider=20OpenCode=20dynamiq?= =?UTF-8?q?ue=20+=20saisie=20provider=20personnalis=C3=A9=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le picker de provider OpenCode du first-run wizard consomme le catalogue dynamique exposé par le backend et propose une option « provider personnalisé » avec un formulaire de saisie libre (id + clé API) quand le provider souhaité n'y figure pas. Co-Authored-By: Claude Opus 4.8 --- .../adapters/http/requestResponseGateways.ts | 1 + frontend/src/adapters/mock/index.ts | 1 + frontend/src/adapters/profile.ts | 1 + frontend/src/domain/index.ts | 20 ++ .../first-run/FirstRunWizard.test.tsx | 196 +++++++++++ .../src/features/first-run/FirstRunWizard.tsx | 311 ++++++++++++++---- frontend/src/ports/index.ts | 9 +- 7 files changed, 470 insertions(+), 69 deletions(-) diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 6d2b0ec..8d01553 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -195,6 +195,7 @@ export class HttpProfileGateway implements ProfileGateway { providerId: input.providerId, model: input.model, apiKey: input.apiKey, + custom: input.custom, }, }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 7e55dac..09ba941 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1374,6 +1374,7 @@ export class MockProfileGateway implements ProfileGateway { model: input.model, // The mock never seals a real secret; the ref is opaque either way. apiKeyRef: `mock-secret-${input.profile.id}`, + custom: input.custom, }, }; const i = this.profiles.findIndex((p) => p.id === saved.id); diff --git a/frontend/src/adapters/profile.ts b/frontend/src/adapters/profile.ts index 3e0ac3e..62a72f7 100644 --- a/frontend/src/adapters/profile.ts +++ b/frontend/src/adapters/profile.ts @@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway { providerId: input.providerId, model: input.model, apiKey: input.apiKey, + custom: input.custom, }, }); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index ca10a2d..e702f4f 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1015,6 +1015,26 @@ export interface OpenCodeProviderConfig { model: string; /** Opaque reference to the sealed API key; never the literal key. */ apiKeyRef: string; + /** + * Additive config for a **custom** provider (endpoint outside the OpenCode + * registry, ticket #92). Absent = known catalogue provider (unchanged + * behaviour); present = arbitrary OpenAI-compatible endpoint. + */ + custom?: CustomProviderConfig; +} + +/** + * Config for a custom OpenCode provider (mirror of the backend + * `CustomProviderConfig`, camelCase wire format), carried by + * {@link OpenCodeProviderConfig.custom}. + */ +export interface CustomProviderConfig { + /** AI SDK npm package used to talk to this provider (e.g. `"@ai-sdk/openai-compatible"`). */ + npm: string; + /** Base URL of the OpenAI-compatible endpoint. */ + baseUrl: string; + /** Optional display label for the model (defaults to the model id). */ + displayName?: string; } /** diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 116555f..ce82b9c 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -430,6 +430,202 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => { }); }); +describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic catalogue)", () => { + const OPENCODE = "OpenCode + llama.cpp"; + + async function goToCloudCustomMode() { + fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" })); + const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`); + fireEvent.change(providerSelect, { target: { value: "__custom__" } }); + } + + it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => { + renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull(); + expect( + screen.getByLabelText(`${OPENCODE} custom provider id`), + ).toBeTruthy(); + expect( + (screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement) + .value, + ).toBe("@ai-sdk/openai-compatible"); + expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy(); + expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy(); + expect( + screen.getByLabelText(`${OPENCODE} custom display name`), + ).toBeTruthy(); + }); + + it("validates the required custom fields on submit", async () => { + renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-custom-secret" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy(); + expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy(); + expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy(); + // npm is pre-filled by default, so it doesn't fail validation here. + expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull(); + }); + + it("saves a custom provider profile with the custom config mapped through", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), { + target: { value: "mon-provider" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), { + target: { value: "@ai-sdk/openai-compatible" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), { + target: { value: "https://api.mon-provider.example/v1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "mon-modele-1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), { + target: { value: "Mon Modèle" }, + }); + const apiKeyInput = screen.getByLabelText( + `${OPENCODE} api key`, + ) as HTMLInputElement; + fireEvent.change(apiKeyInput, { target: { value: "sk-custom-secret" } }); + + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.opencodeProvider?.providerId).toBe("mon-provider"); + expect(opencode?.opencodeProvider?.model).toBe("mon-modele-1"); + expect(opencode?.opencodeProvider?.custom).toEqual({ + npm: "@ai-sdk/openai-compatible", + baseUrl: "https://api.mon-provider.example/v1", + displayName: "Mon Modèle", + }); + }); + expect(apiKeyInput.value).toBe(""); + }); + + it("omits displayName and custom when left blank / not in custom mode", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), { + target: { value: "mon-provider" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), { + target: { value: "https://api.mon-provider.example/v1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), { + target: { value: "mon-modele-1" }, + }); + fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), { + target: { value: "sk-custom-secret" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const opencode = saved.find((p) => p.command === "opencode"); + expect(opencode?.opencodeProvider?.custom?.displayName).toBeUndefined(); + }); + }); + + it('"← Choisir un provider du catalogue" switches back to the cascade', async () => { + renderWizard(); + await waitForLoaded(); + await goToCloudCustomMode(); + + fireEvent.click( + screen.getByRole("button", { name: "← Choisir un provider du catalogue" }), + ); + + expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy(); + expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull(); + }); + + it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => { + const profile = new MockProfileGateway(); + await profile.configureProfiles([ + { + id: "cfg-oc-custom-1", + name: "Mon provider via OpenCode", + command: "opencode", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: "opencode --version", + cwdTemplate: "{projectRoot}", + structuredAdapter: "openCode", + opencodeProvider: { + providerId: "mon-provider", + model: "mon-modele-1", + apiKeyRef: "secret-ref-2", + custom: { + npm: "@ai-sdk/openai-compatible", + baseUrl: "https://api.mon-provider.example/v1", + displayName: "Mon Modèle", + }, + }, + }, + ]); + + const gateways = { + profile, + modelServer: new MockModelServerGateway(), + } as unknown as Gateways; + render( + + + , + ); + await waitForLoaded(); + + const row = within( + screen.getByLabelText("use Mon provider via OpenCode").closest("li")!, + ); + expect( + row.getByRole("radio", { name: "Provider cloud" }).getAttribute( + "aria-checked", + ), + ).toBe("true"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement) + .value, + ).toBe("mon-provider"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement) + .value, + ).toBe("@ai-sdk/openai-compatible"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement) + .value, + ).toBe("https://api.mon-provider.example/v1"); + expect( + (row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value, + ).toBe("mon-modele-1"); + expect( + (row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement) + .value, + ).toBe("Mon Modèle"); + expect( + (row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement) + .value, + ).toBe(""); + }); +}); + describe("FirstRunWizard — several local OpenCode profiles (F36)", () => { const OPENCODE = "OpenCode + llama.cpp"; const CLONE1 = `${OPENCODE} (copy 1)`; diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 5de1c05..d56632f 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -416,14 +416,25 @@ interface CloudFieldErrors { providerId?: string; model?: string; apiKey?: string; + npm?: string; + baseUrl?: string; } +/** Sentinel ` - {catalog.providers?.map((p) => ( - - ))} - - {fieldErrors.providerId && ( - {fieldErrors.providerId} - )} - + {mode === "catalog" && ( + <> + - + + + )} + + {mode === "custom" && ( +
+ + Provider personnalisé + + + + + + + + + + + + + +
+ )}