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:
@ -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)`;
|
||||
|
||||
Reference in New Issue
Block a user