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,
|
providerId: input.providerId,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
apiKey: input.apiKey,
|
apiKey: input.apiKey,
|
||||||
|
custom: input.custom,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1374,6 +1374,7 @@ export class MockProfileGateway implements ProfileGateway {
|
|||||||
model: input.model,
|
model: input.model,
|
||||||
// The mock never seals a real secret; the ref is opaque either way.
|
// The mock never seals a real secret; the ref is opaque either way.
|
||||||
apiKeyRef: `mock-secret-${input.profile.id}`,
|
apiKeyRef: `mock-secret-${input.profile.id}`,
|
||||||
|
custom: input.custom,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const i = this.profiles.findIndex((p) => p.id === saved.id);
|
const i = this.profiles.findIndex((p) => p.id === saved.id);
|
||||||
|
|||||||
@ -74,6 +74,7 @@ export class TauriProfileGateway implements ProfileGateway {
|
|||||||
providerId: input.providerId,
|
providerId: input.providerId,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
apiKey: input.apiKey,
|
apiKey: input.apiKey,
|
||||||
|
custom: input.custom,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1015,6 +1015,26 @@ export interface OpenCodeProviderConfig {
|
|||||||
model: string;
|
model: string;
|
||||||
/** Opaque reference to the sealed API key; never the literal key. */
|
/** Opaque reference to the sealed API key; never the literal key. */
|
||||||
apiKeyRef: string;
|
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)", () => {
|
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||||
const OPENCODE = "OpenCode + llama.cpp";
|
const OPENCODE = "OpenCode + llama.cpp";
|
||||||
const CLONE1 = `${OPENCODE} (copy 1)`;
|
const CLONE1 = `${OPENCODE} (copy 1)`;
|
||||||
|
|||||||
@ -416,14 +416,25 @@ interface CloudFieldErrors {
|
|||||||
providerId?: string;
|
providerId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
apiKey?: 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 ➜
|
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
|
||||||
* model (cascading selects fed by the static catalogue) ➜ API key. The key is
|
* model (cascading selects fed by the dynamic catalogue, now up to ~170
|
||||||
* never pre-filled (create or edit) since the backend never returns it — it
|
* entries) ➜ API key, or — via the "Autre / personnalisé" catalogue entry — a
|
||||||
* resigns whatever literal it receives on every save, so editing an existing
|
* free-form OpenAI-compatible endpoint (provider id, npm SDK package, base
|
||||||
* cloud profile requires re-entering it every time (§3 of the spec).
|
* 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({
|
function OpenCodeProviderFields({
|
||||||
profile,
|
profile,
|
||||||
@ -436,8 +447,17 @@ function OpenCodeProviderFields({
|
|||||||
}) {
|
}) {
|
||||||
const { profile: profileGateway } = useGateways();
|
const { profile: profileGateway } = useGateways();
|
||||||
const existing = profile.opencodeProvider;
|
const existing = profile.opencodeProvider;
|
||||||
|
const [mode, setMode] = useState<"catalog" | "custom">(
|
||||||
|
existing?.custom ? "custom" : "catalog",
|
||||||
|
);
|
||||||
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
||||||
const [model, setModel] = useState(existing?.model ?? "");
|
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 [apiKey, setApiKey] = useState("");
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@ -448,13 +468,34 @@ function OpenCodeProviderFields({
|
|||||||
const models =
|
const models =
|
||||||
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
||||||
const catalogReady = catalog.providers !== null && !catalog.loading;
|
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 =
|
const saveDisabled =
|
||||||
saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0;
|
saving ||
|
||||||
|
apiKey.length === 0 ||
|
||||||
|
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const errors: CloudFieldErrors = {};
|
const errors: CloudFieldErrors = {};
|
||||||
if (providerId.length === 0) errors.providerId = "Le provider est obligatoire.";
|
if (providerId.trim().length === 0) {
|
||||||
if (model.length === 0) errors.model = "Le modèle est obligatoire.";
|
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.";
|
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
if (Object.keys(errors).length > 0) return;
|
if (Object.keys(errors).length > 0) return;
|
||||||
@ -464,9 +505,21 @@ function OpenCodeProviderFields({
|
|||||||
try {
|
try {
|
||||||
const saved = await profileGateway.saveOpenCodeProviderProfile({
|
const saved = await profileGateway.saveOpenCodeProviderProfile({
|
||||||
profile,
|
profile,
|
||||||
providerId,
|
providerId: providerId.trim(),
|
||||||
model,
|
model: model.trim(),
|
||||||
apiKey,
|
apiKey,
|
||||||
|
...(mode === "custom"
|
||||||
|
? {
|
||||||
|
custom: {
|
||||||
|
npm: customNpm.trim(),
|
||||||
|
baseUrl: customBaseUrl.trim(),
|
||||||
|
displayName:
|
||||||
|
customDisplayName.trim().length > 0
|
||||||
|
? customDisplayName.trim()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
onChange(saved);
|
onChange(saved);
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
@ -503,66 +556,188 @@ function OpenCodeProviderFields({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="flex flex-col gap-1">
|
{mode === "catalog" && (
|
||||||
<Caption>Provider</Caption>
|
<>
|
||||||
<select
|
<label className="flex flex-col gap-1">
|
||||||
aria-label={`${profile.name} provider`}
|
<Caption>Provider</Caption>
|
||||||
value={providerId}
|
{catalogReady && (
|
||||||
disabled={!catalogReady}
|
<input
|
||||||
onChange={(e) => {
|
type="text"
|
||||||
setProviderId(e.target.value);
|
aria-label={`${profile.name} provider search`}
|
||||||
setModel("");
|
placeholder="Rechercher un provider…"
|
||||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
value={providerFilter}
|
||||||
}}
|
onChange={(e) => setProviderFilter(e.target.value)}
|
||||||
className={cn(
|
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
|
||||||
"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",
|
<select
|
||||||
)}
|
aria-label={`${profile.name} provider`}
|
||||||
>
|
value={providerId}
|
||||||
<option value="" disabled>
|
disabled={!catalogReady}
|
||||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
onChange={(e) => {
|
||||||
</option>
|
const v = e.target.value;
|
||||||
{catalog.providers?.map((p) => (
|
if (v === CUSTOM_PROVIDER_VALUE) {
|
||||||
<option key={p.providerId} value={p.providerId}>
|
setMode("custom");
|
||||||
{p.displayName}
|
setProviderId("");
|
||||||
</option>
|
setModel("");
|
||||||
))}
|
} else {
|
||||||
</select>
|
setProviderId(v);
|
||||||
{fieldErrors.providerId && (
|
setModel("");
|
||||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
}
|
||||||
)}
|
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||||
</label>
|
}}
|
||||||
|
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">
|
<label className="flex flex-col gap-1">
|
||||||
<Caption>Modèle</Caption>
|
<Caption>Modèle</Caption>
|
||||||
<select
|
<select
|
||||||
aria-label={`${profile.name} model`}
|
aria-label={`${profile.name} model`}
|
||||||
value={model}
|
value={model}
|
||||||
disabled={providerId.length === 0}
|
disabled={providerId.length === 0}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setModel(e.target.value);
|
setModel(e.target.value);
|
||||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
fieldErrors.model ? "border-danger" : "border-border",
|
fieldErrors.model ? "border-danger" : "border-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||||
</option>
|
</option>
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<option key={m} value={m}>
|
<option key={m} value={m}>
|
||||||
{m}
|
{m}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{fieldErrors.model && (
|
{fieldErrors.model && (
|
||||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||||
)}
|
)}
|
||||||
</label>
|
</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">
|
<label className="flex flex-col gap-1">
|
||||||
<Caption>Clé API</Caption>
|
<Caption>Clé API</Caption>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
AgentDrift,
|
AgentDrift,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
AppExitWorkGuardState,
|
AppExitWorkGuardState,
|
||||||
|
CustomProviderConfig,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EmbedderEngines,
|
EmbedderEngines,
|
||||||
EmbedderProfile,
|
EmbedderProfile,
|
||||||
@ -701,12 +702,18 @@ export interface CloneOpenCodeProfileFromSeedInput {
|
|||||||
export interface SaveOpenCodeProviderProfileInput {
|
export interface SaveOpenCodeProviderProfileInput {
|
||||||
/** The profile to create or replace (by id). */
|
/** The profile to create or replace (by id). */
|
||||||
profile: AgentProfile;
|
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;
|
providerId: string;
|
||||||
/** Model name served by this provider. */
|
/** Model name served by this provider. */
|
||||||
model: string;
|
model: string;
|
||||||
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
|
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
|
||||||
apiKey: string;
|
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