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:
@ -195,6 +195,7 @@ export class HttpProfileGateway implements ProfileGateway {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
custom: input.custom,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)`;
|
||||
|
||||
@ -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 it — it
|
||||
* 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 entry — a
|
||||
* 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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user