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

@ -195,6 +195,7 @@ export class HttpProfileGateway implements ProfileGateway {
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
},
});
}

View File

@ -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);

View File

@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway {
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
},
});
}

View File

@ -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;
}
/**

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)`;

View File

@ -416,14 +416,25 @@ interface CloudFieldErrors {
providerId?: string;
model?: string;
apiKey?: string;
npm?: string;
baseUrl?: string;
}
/** Sentinel `<option>` value picking the custom-provider sub-form. */
const CUSTOM_PROVIDER_VALUE = "__custom__";
/** Default AI SDK package pre-filled for a fresh custom-provider draft. */
const DEFAULT_CUSTOM_NPM = "@ai-sdk/openai-compatible";
/**
* 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 itit
* resigns whatever literal it receives on every save, so editing an existing
* cloud profile requires re-entering it every time (§3 of the spec).
* model (cascading selects fed by the dynamic catalogue, now up to ~170
* entries) ➜ API key, or — via the "Autre / personnalisé" catalogue entrya
* free-form OpenAI-compatible endpoint (provider id, npm SDK package, base
* URL, model id, optional display name). The key is never pre-filled (create
* or edit) since the backend never returns it — it reseals 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,
@ -436,8 +447,17 @@ function OpenCodeProviderFields({
}) {
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [mode, setMode] = useState<"catalog" | "custom">(
existing?.custom ? "custom" : "catalog",
);
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
const [model, setModel] = useState(existing?.model ?? "");
const [providerFilter, setProviderFilter] = useState("");
const [customNpm, setCustomNpm] = useState(existing?.custom?.npm ?? DEFAULT_CUSTOM_NPM);
const [customBaseUrl, setCustomBaseUrl] = useState(existing?.custom?.baseUrl ?? "");
const [customDisplayName, setCustomDisplayName] = useState(
existing?.custom?.displayName ?? "",
);
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
@ -448,13 +468,34 @@ function OpenCodeProviderFields({
const models =
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
const catalogReady = catalog.providers !== null && !catalog.loading;
const filteredProviders = (catalog.providers ?? []).filter((p) => {
// Always keep the current selection visible even if it no longer matches
// the filter, so the <select> doesn't silently lose its value.
if (p.providerId === providerId) return true;
const q = providerFilter.trim().toLowerCase();
if (q.length === 0) return true;
return (
p.displayName.toLowerCase().includes(q) ||
p.providerId.toLowerCase().includes(q)
);
});
const saveDisabled =
saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0;
saving ||
apiKey.length === 0 ||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
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 (providerId.trim().length === 0) {
errors.providerId = "Le provider est obligatoire.";
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
@ -464,9 +505,21 @@ function OpenCodeProviderFields({
try {
const saved = await profileGateway.saveOpenCodeProviderProfile({
profile,
providerId,
model,
providerId: providerId.trim(),
model: model.trim(),
apiKey,
...(mode === "custom"
? {
custom: {
npm: customNpm.trim(),
baseUrl: customBaseUrl.trim(),
displayName:
customDisplayName.trim().length > 0
? customDisplayName.trim()
: undefined,
},
}
: {}),
});
onChange(saved);
setApiKey("");
@ -503,66 +556,188 @@ function OpenCodeProviderFields({
</div>
)}
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
setProviderId(e.target.value);
setModel("");
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.providerId ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{catalog.providers?.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/>
)}
<select
aria-label={`${profile.name} provider`}
value={providerId}
disabled={!catalogReady}
onChange={(e) => {
const v = e.target.value;
if (v === CUSTOM_PROVIDER_VALUE) {
setMode("custom");
setProviderId("");
setModel("");
} else {
setProviderId(v);
setModel("");
}
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.providerId ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
</option>
{filteredProviders.map((p) => (
<option key={p.providerId} value={p.providerId}>
{p.displayName}
</option>
))}
<option value={CUSTOM_PROVIDER_VALUE}>Autre / personnalisé</option>
</select>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<select
aria-label={`${profile.name} model`}
value={model}
disabled={providerId.length === 0}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.model ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<select
aria-label={`${profile.name} model`}
value={model}
disabled={providerId.length === 0}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
className={cn(
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
fieldErrors.model ? "border-danger" : "border-border",
)}
>
<option value="" disabled>
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
</>
)}
{mode === "custom" && (
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider personnalisé
</legend>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setMode("catalog");
setProviderId("");
setModel("");
setFieldErrors({});
}}
>
Choisir un provider du catalogue
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
value={providerId}
placeholder="ex. mon-provider"
invalid={Boolean(fieldErrors.providerId)}
onChange={(e) => {
setProviderId(e.target.value);
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
}}
/>
{fieldErrors.providerId && (
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Paquet npm</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
value={customNpm}
placeholder={DEFAULT_CUSTOM_NPM}
invalid={Boolean(fieldErrors.npm)}
onChange={(e) => {
setCustomNpm(e.target.value);
setFieldErrors((prev) => ({ ...prev, npm: undefined }));
}}
/>
{fieldErrors.npm && (
<small className="text-xs text-danger">{fieldErrors.npm}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>URL de base</Caption>
<Input
aria-label={`${profile.name} custom base url`}
value={customBaseUrl}
placeholder="https://api.mon-provider.example/v1"
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => {
setCustomBaseUrl(e.target.value);
setFieldErrors((prev) => ({ ...prev, baseUrl: undefined }));
}}
/>
{fieldErrors.baseUrl && (
<small className="text-xs text-danger">{fieldErrors.baseUrl}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Input
aria-label={`${profile.name} model`}
value={model}
placeholder="ex. mon-modele-1"
invalid={Boolean(fieldErrors.model)}
onChange={(e) => {
setModel(e.target.value);
setFieldErrors((prev) => ({ ...prev, model: undefined }));
}}
/>
{fieldErrors.model && (
<small className="text-xs text-danger">{fieldErrors.model}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Libellé du modèle (optionnel)</Caption>
<Input
aria-label={`${profile.name} custom display name`}
value={customDisplayName}
placeholder="ex. Mon modèle"
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
</fieldset>
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>

View File

@ -13,6 +13,7 @@ import type {
AgentDrift,
AgentProfile,
AppExitWorkGuardState,
CustomProviderConfig,
DomainEvent,
EmbedderEngines,
EmbedderProfile,
@ -701,12 +702,18 @@ export interface CloneOpenCodeProfileFromSeedInput {
export interface SaveOpenCodeProviderProfileInput {
/** The profile to create or replace (by id). */
profile: AgentProfile;
/** Provider id in the OpenCode registry (e.g. `"anthropic"`). */
/** Provider id — a catalogue entry, or a free-form id when {@link custom} is set. */
providerId: string;
/** Model name served by this provider. */
model: string;
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
apiKey: string;
/**
* Optional custom-provider configuration (ticket #92): an endpoint outside
* the OpenCode registry. Absent/`undefined` = known catalogue provider
* (unchanged behaviour).
*/
custom?: CustomProviderConfig;
}
/**