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 `