feat(frontend): provider OpenCode dynamique + saisie provider personnalisé (#92)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:44:02 +02:00
parent e943a0efed
commit 1784027f5d
7 changed files with 470 additions and 69 deletions

View File

@ -430,6 +430,202 @@ describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
});
});
describe("FirstRunWizard — OpenCode custom cloud provider (ticket #92, dynamic catalogue)", () => {
const OPENCODE = "OpenCode + llama.cpp";
async function goToCloudCustomMode() {
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
fireEvent.change(providerSelect, { target: { value: "__custom__" } });
}
it("selecting 'Autre / personnalisé' swaps the cascade for free-form fields", async () => {
renderWizard();
await waitForLoaded();
await goToCloudCustomMode();
expect(screen.queryByLabelText(`${OPENCODE} provider search`)).toBeNull();
expect(
screen.getByLabelText(`${OPENCODE} custom provider id`),
).toBeTruthy();
expect(
(screen.getByLabelText(`${OPENCODE} custom npm package`) as HTMLInputElement)
.value,
).toBe("@ai-sdk/openai-compatible");
expect(screen.getByLabelText(`${OPENCODE} custom base url`)).toBeTruthy();
expect(screen.getByLabelText(`${OPENCODE} model`)).toBeTruthy();
expect(
screen.getByLabelText(`${OPENCODE} custom display name`),
).toBeTruthy();
});
it("validates the required custom fields on submit", async () => {
renderWizard();
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
target: { value: "sk-custom-secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
expect(screen.getByText("L'URL de base est obligatoire.")).toBeTruthy();
// npm is pre-filled by default, so it doesn't fail validation here.
expect(screen.queryByText("Le paquet npm est obligatoire.")).toBeNull();
});
it("saves a custom provider profile with the custom config mapped through", async () => {
const { profile } = renderWizard();
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
target: { value: "mon-provider" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom npm package`), {
target: { value: "@ai-sdk/openai-compatible" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
target: { value: "https://api.mon-provider.example/v1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "mon-modele-1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom display name`), {
target: { value: "Mon Modèle" },
});
const apiKeyInput = screen.getByLabelText(
`${OPENCODE} api key`,
) as HTMLInputElement;
fireEvent.change(apiKeyInput, { target: { value: "sk-custom-secret" } });
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const opencode = saved.find((p) => p.command === "opencode");
expect(opencode?.opencodeProvider?.providerId).toBe("mon-provider");
expect(opencode?.opencodeProvider?.model).toBe("mon-modele-1");
expect(opencode?.opencodeProvider?.custom).toEqual({
npm: "@ai-sdk/openai-compatible",
baseUrl: "https://api.mon-provider.example/v1",
displayName: "Mon Modèle",
});
});
expect(apiKeyInput.value).toBe("");
});
it("omits displayName and custom when left blank / not in custom mode", async () => {
const { profile } = renderWizard();
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom provider id`), {
target: { value: "mon-provider" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} custom base url`), {
target: { value: "https://api.mon-provider.example/v1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "mon-modele-1" },
});
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
target: { value: "sk-custom-secret" },
});
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const opencode = saved.find((p) => p.command === "opencode");
expect(opencode?.opencodeProvider?.custom?.displayName).toBeUndefined();
});
});
it('"← Choisir un provider du catalogue" switches back to the cascade', async () => {
renderWizard();
await waitForLoaded();
await goToCloudCustomMode();
fireEvent.click(
screen.getByRole("button", { name: "← Choisir un provider du catalogue" }),
);
expect(screen.getByLabelText(`${OPENCODE} provider`)).toBeTruthy();
expect(screen.queryByLabelText(`${OPENCODE} custom provider id`)).toBeNull();
});
it("editing an existing custom-provider profile preselects custom mode and prefills fields (except the key)", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "cfg-oc-custom-1",
name: "Mon provider via OpenCode",
command: "opencode",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "opencode --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "openCode",
opencodeProvider: {
providerId: "mon-provider",
model: "mon-modele-1",
apiKeyRef: "secret-ref-2",
custom: {
npm: "@ai-sdk/openai-compatible",
baseUrl: "https://api.mon-provider.example/v1",
displayName: "Mon Modèle",
},
},
},
]);
const gateways = {
profile,
modelServer: new MockModelServerGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<FirstRunWizard forceOpen />
</DIProvider>,
);
await waitForLoaded();
const row = within(
screen.getByLabelText("use Mon provider via OpenCode").closest("li")!,
);
expect(
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
"aria-checked",
),
).toBe("true");
expect(
(row.getByLabelText("Mon provider via OpenCode custom provider id") as HTMLInputElement)
.value,
).toBe("mon-provider");
expect(
(row.getByLabelText("Mon provider via OpenCode custom npm package") as HTMLInputElement)
.value,
).toBe("@ai-sdk/openai-compatible");
expect(
(row.getByLabelText("Mon provider via OpenCode custom base url") as HTMLInputElement)
.value,
).toBe("https://api.mon-provider.example/v1");
expect(
(row.getByLabelText("Mon provider via OpenCode model") as HTMLInputElement).value,
).toBe("mon-modele-1");
expect(
(row.getByLabelText("Mon provider via OpenCode custom display name") as HTMLInputElement)
.value,
).toBe("Mon Modèle");
expect(
(row.getByLabelText("Mon provider via OpenCode api key") as HTMLInputElement)
.value,
).toBe("");
});
});
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
const OPENCODE = "OpenCode + llama.cpp";
const CLONE1 = `${OPENCODE} (copy 1)`;