feat: implémentation et QA ticket99 - agent model configuration v2

- backend A-C: VO Codex/Claude, renderers/projecteurs modèle, SecretRef/env, catalogues/use cases/Tauri commands
- frontend D: profils Codex/Claude provider->model->secret, validations, conservation SecretRef
- fix: app-tauri embedded_server isolant IDEA_WEB_ROOT

QA: backend 57/57 tests, frontend 74/74 tests OK
This commit is contained in:
2026-07-26 11:56:25 +02:00
parent 13fb538880
commit dae07d35bb
27 changed files with 2358 additions and 143 deletions

View File

@ -15,6 +15,8 @@ import type {
Agent,
AgentDrift,
AgentProfile,
ClaudeProviderCatalogEntry,
CodexProviderCatalogEntry,
EffectivePermissions,
EmbedderEngines,
EmbedderProfile,
@ -65,6 +67,8 @@ import type {
PermissionGateway,
ProfileGateway,
ProjectGateway,
SaveClaudeProviderProfileInput,
SaveCodexProviderProfileInput,
SaveOpenCodeProviderProfileInput,
SkillGateway,
TemplateGateway,
@ -202,6 +206,37 @@ export class HttpProfileGateway implements ProfileGateway {
},
});
}
listCodexProviders(): Promise<CodexProviderCatalogEntry[]> {
return this.http.invoke<CodexProviderCatalogEntry[]>("list_codex_providers");
}
saveCodexProviderProfile(
input: SaveCodexProviderProfileInput,
): Promise<AgentProfile> {
return this.http.invoke<AgentProfile>("save_codex_provider_profile", {
request: {
profile: input.profile,
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
},
});
}
listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]> {
return this.http.invoke<ClaudeProviderCatalogEntry[]>("list_claude_providers");
}
saveClaudeProviderProfile(
input: SaveClaudeProviderProfileInput,
): Promise<AgentProfile> {
return this.http.invoke<AgentProfile>("save_claude_provider_profile", {
request: {
profile: input.profile,
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
},
});
}
}
export class HttpModelServerGateway implements ModelServerGateway {

View File

@ -9,6 +9,8 @@ import type {
AgentDrift,
AppExitWorkGuardState,
AgentProfile,
ClaudeProviderCatalogEntry,
CodexProviderCatalogEntry,
DiagnosticWarning,
DomainEvent,
EmbedderEngines,
@ -103,6 +105,8 @@ import type {
ReattachResult,
RemoteGateway,
ReviewPluginPackageInput,
SaveClaudeProviderProfileInput,
SaveCodexProviderProfileInput,
SaveOpenCodeProviderProfileInput,
SkillGateway,
StoppedLiveAgent,
@ -1250,6 +1254,7 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: "claude --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "claude",
},
{
id: "mock-codex",
@ -1259,6 +1264,7 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "codex --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
},
{
id: "mock-opencode",
@ -1291,6 +1297,25 @@ const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
},
];
/** Static mock catalogue mirroring the backend Codex provider list. */
const MOCK_CODEX_PROVIDERS: CodexProviderCatalogEntry[] = [
{
providerId: "openai",
displayName: "OpenAI",
models: ["gpt-5", "gpt-5-mini", "gpt-5-codex", "o3"],
customSupported: true,
},
];
/** Static mock catalogue mirroring the backend Claude provider list. */
const MOCK_CLAUDE_PROVIDERS: ClaudeProviderCatalogEntry[] = [
{
providerId: "anthropic",
displayName: "Anthropic",
models: ["claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-3-5"],
},
];
/**
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
* the wizard can be driven and tested fully offline. By default it reports the
@ -1386,6 +1411,53 @@ export class MockProfileGateway implements ProfileGateway {
this.configured = true;
return structuredClone(saved);
}
async listCodexProviders(): Promise<CodexProviderCatalogEntry[]> {
return structuredClone(MOCK_CODEX_PROVIDERS);
}
async saveCodexProviderProfile(
input: SaveCodexProviderProfileInput,
): Promise<AgentProfile> {
const saved: AgentProfile = {
...structuredClone(input.profile),
codexProvider: {
providerId: input.providerId,
model: input.model,
apiKeyRef:
input.profile.codexProvider?.apiKeyRef ?? `mock-secret-${input.profile.id}`,
custom: input.custom,
},
};
const i = this.profiles.findIndex((p) => p.id === saved.id);
if (i >= 0) this.profiles[i] = saved;
else this.profiles.push(saved);
this.configured = true;
return structuredClone(saved);
}
async listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]> {
return structuredClone(MOCK_CLAUDE_PROVIDERS);
}
async saveClaudeProviderProfile(
input: SaveClaudeProviderProfileInput,
): Promise<AgentProfile> {
const saved: AgentProfile = {
...structuredClone(input.profile),
claudeProvider: {
providerId: input.providerId,
model: input.model,
apiKeyRef:
input.profile.claudeProvider?.apiKeyRef ?? `mock-secret-${input.profile.id}`,
},
};
const i = this.profiles.findIndex((p) => p.id === saved.id);
if (i >= 0) this.profiles[i] = saved;
else this.profiles.push(saved);
this.configured = true;
return structuredClone(saved);
}
}
/**

View File

@ -10,6 +10,8 @@ import { invoke } from "@tauri-apps/api/core";
import type {
AgentProfile,
ClaudeProviderCatalogEntry,
CodexProviderCatalogEntry,
FirstRunState,
OpenCodeProviderCatalogEntry,
ProfileAvailability,
@ -17,6 +19,8 @@ import type {
import type {
CloneOpenCodeProfileFromSeedInput,
ProfileGateway,
SaveClaudeProviderProfileInput,
SaveCodexProviderProfileInput,
SaveOpenCodeProviderProfileInput,
} from "@/ports";
@ -78,4 +82,39 @@ export class TauriProfileGateway implements ProfileGateway {
},
});
}
listCodexProviders(): Promise<CodexProviderCatalogEntry[]> {
return invoke<CodexProviderCatalogEntry[]>("list_codex_providers");
}
saveCodexProviderProfile(
input: SaveCodexProviderProfileInput,
): Promise<AgentProfile> {
return invoke<AgentProfile>("save_codex_provider_profile", {
request: {
profile: input.profile,
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
},
});
}
listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]> {
return invoke<ClaudeProviderCatalogEntry[]>("list_claude_providers");
}
saveClaudeProviderProfile(
input: SaveClaudeProviderProfileInput,
): Promise<AgentProfile> {
return invoke<AgentProfile>("save_claude_provider_profile", {
request: {
profile: input.profile,
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
},
});
}
}

View File

@ -1068,6 +1068,36 @@ export interface OpenCodeProviderConfig {
custom?: CustomProviderConfig;
}
/**
* Configuration for a Codex profile backed by a provider/model pair (ticket
* #99). `apiKeyRef` is an opaque backend SecretStore reference; the literal
* secret is only sent through {@link ProfileGateway.saveCodexProviderProfile}.
*/
export interface CodexProviderConfig {
/** Provider id used as Codex's `model_provider` (e.g. `"openai"`). */
providerId: string;
/** Model name written into Codex's isolated config. */
model: string;
/** Opaque reference to the sealed API key; never the literal key. */
apiKeyRef: string;
/** Optional custom OpenAI-compatible endpoint for this Codex provider. */
custom?: CodexCustomProviderConfig;
}
/**
* Configuration for a Claude profile backed by a provider/model pair (ticket
* #99). `apiKeyRef` is an opaque backend SecretStore reference; the literal
* secret is only sent through {@link ProfileGateway.saveClaudeProviderProfile}.
*/
export interface ClaudeProviderConfig {
/** Provider id. V1 backend exposes `"anthropic"`. */
providerId: string;
/** Model name written into Claude's isolated settings. */
model: string;
/** Opaque reference to the sealed API key; never the literal key. */
apiKeyRef: string;
}
/**
* Config for a custom OpenCode provider (mirror of the backend
* `CustomProviderConfig`, camelCase wire format), carried by
@ -1082,6 +1112,18 @@ export interface CustomProviderConfig {
displayName?: string;
}
/**
* Config for a custom Codex provider (mirror of the backend
* `CodexCustomProviderConfig`, camelCase wire format), carried by
* {@link CodexProviderConfig.custom}.
*/
export interface CodexCustomProviderConfig {
/** Base URL of the OpenAI-compatible endpoint. */
baseUrl: string;
/** Optional display label written into Codex's provider table. */
displayName?: string;
}
/**
* One entry of the static OpenCode cloud-provider catalogue (mirror of the
* backend `OpenCodeProviderDto`), returned by
@ -1096,6 +1138,28 @@ export interface OpenCodeProviderCatalogEntry {
models: string[];
}
/** One entry of the static Codex provider catalogue (ticket #99). */
export interface CodexProviderCatalogEntry {
/** Provider id used as Codex's `model_provider`. */
providerId: string;
/** Human-readable label for the picker UI. */
displayName: string;
/** Model names this provider serves, offered for selection. */
models: string[];
/** Whether this provider supports a custom endpoint in the UI. */
customSupported: boolean;
}
/** One entry of the static Claude provider catalogue (ticket #99). */
export interface ClaudeProviderCatalogEntry {
/** Provider id. V1 backend exposes `"anthropic"`. */
providerId: string;
/** Human-readable label for the picker UI. */
displayName: string;
/** Model names this provider serves, offered for selection. */
models: string[];
}
/**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line.
@ -1132,6 +1196,10 @@ export interface AgentProfile {
* both.
*/
opencodeProvider?: OpenCodeProviderConfig;
/** Codex provider/model config (ticket #99). */
codexProvider?: CodexProviderConfig;
/** Claude provider/model config (ticket #99). */
claudeProvider?: ClaudeProviderConfig;
}
/** Availability of a candidate profile after detection (mirror of the DTO). */

View File

@ -183,6 +183,131 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
});
});
describe("FirstRunWizard — Codex/Claude provider configuration (ticket #99)", () => {
it("saves a Codex provider profile and clears the literal key", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByLabelText("use OpenAI Codex CLI"));
const row = within(
screen.getByLabelText("use OpenAI Codex CLI").closest("li")!,
);
const providerSelect = await row.findByLabelText("OpenAI Codex CLI provider");
const modelSelect = row.getByLabelText(
"OpenAI Codex CLI model",
) as HTMLSelectElement;
expect(modelSelect.disabled).toBe(true);
fireEvent.change(providerSelect, { target: { value: "openai" } });
expect(modelSelect.value).toBe("");
expect(modelSelect.disabled).toBe(false);
fireEvent.change(modelSelect, { target: { value: "gpt-5-codex" } });
fireEvent.change(row.getByLabelText("OpenAI Codex CLI api key"), {
target: { value: "sk-codex-secret" },
});
fireEvent.click(row.getByRole("button", { name: "Enregistrer Codex" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const codex = saved.find((p) => p.command === "codex");
expect(codex?.codexProvider).toEqual({
providerId: "openai",
model: "gpt-5-codex",
apiKeyRef: "mock-secret-mock-codex",
custom: undefined,
});
expect(JSON.stringify(codex)).not.toContain("sk-codex-secret");
});
expect((row.getByLabelText("OpenAI Codex CLI api key") as HTMLInputElement).value).toBe("");
});
it("saves a Claude provider profile without exposing the literal key", async () => {
const { profile } = renderWizard();
await waitForLoaded();
const claudeToggle = screen.getByLabelText(
"use Claude Code",
) as HTMLInputElement;
if (!claudeToggle.checked) fireEvent.click(claudeToggle);
const row = within(claudeToggle.closest("li")!);
fireEvent.change(await row.findByLabelText("Claude Code provider"), {
target: { value: "anthropic" },
});
fireEvent.change(row.getByLabelText("Claude Code model"), {
target: { value: "claude-sonnet-4-5" },
});
const apiKey = row.getByLabelText("Claude Code api key") as HTMLInputElement;
fireEvent.change(apiKey, { target: { value: "sk-claude-secret" } });
fireEvent.click(row.getByRole("button", { name: "Enregistrer Claude" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const claude = saved.find((p) => p.command === "claude");
expect(claude?.claudeProvider).toEqual({
providerId: "anthropic",
model: "claude-sonnet-4-5",
apiKeyRef: "mock-secret-mock-claude",
});
expect(JSON.stringify(claude)).not.toContain("sk-claude-secret");
});
expect(apiKey.value).toBe("");
});
it("keeps an existing Codex SecretRef when editing provider/model", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "codex-existing",
name: "Codex configured",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "codex --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
codexProvider: {
providerId: "openai",
model: "gpt-5-mini",
apiKeyRef: "existing-secret-ref",
},
},
]);
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 Codex configured").closest("li")!,
);
expect((row.getByLabelText("Codex configured api key") as HTMLInputElement).value).toBe("");
fireEvent.change(row.getByLabelText("Codex configured model"), {
target: { value: "gpt-5" },
});
fireEvent.change(row.getByLabelText("Codex configured api key"), {
target: { value: "sk-new-secret" },
});
fireEvent.click(row.getByRole("button", { name: "Enregistrer Codex" }));
await waitFor(async () => {
const [saved] = await profile.listProfiles();
expect(saved.codexProvider?.model).toBe("gpt-5");
expect(saved.codexProvider?.apiKeyRef).toBe("existing-secret-ref");
expect(JSON.stringify(saved)).not.toContain("sk-new-secret");
});
});
});
describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
const OPENCODE = "OpenCode + llama.cpp";

View File

@ -19,6 +19,9 @@ import { useCallback, useEffect, useState } from "react";
import type {
AgentProfile,
ClaudeProviderCatalogEntry,
CodexCustomProviderConfig,
CodexProviderCatalogEntry,
GatewayError,
HttpChatConfig,
LocalModelServerConfig,
@ -61,6 +64,20 @@ interface OpenCodeProviderCatalog {
reload: () => void;
}
interface CodexProviderCatalog {
providers: CodexProviderCatalogEntry[] | null;
loading: boolean;
error: string | null;
reload: () => void;
}
interface ClaudeProviderCatalog {
providers: ClaudeProviderCatalogEntry[] | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/**
* Loads the static OpenCode cloud-provider catalogue once for the whole
* wizard (every Cloud row shares it), so the provider/model pickers can be
@ -94,6 +111,62 @@ function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
return { providers, loading, error, reload: () => void load() };
}
function useCodexProviderCatalog(): CodexProviderCatalog {
const { profile } = useGateways();
const [providers, setProviders] = useState<CodexProviderCatalogEntry[] | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setProviders(await profile.listCodexProviders());
} catch (e) {
setProviders(null);
setError(describeError(e));
} finally {
setLoading(false);
}
}, [profile]);
useEffect(() => {
void load();
}, [load]);
return { providers, loading, error, reload: () => void load() };
}
function useClaudeProviderCatalog(): ClaudeProviderCatalog {
const { profile } = useGateways();
const [providers, setProviders] = useState<ClaudeProviderCatalogEntry[] | null>(
null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setProviders(await profile.listClaudeProviders());
} catch (e) {
setProviders(null);
setError(describeError(e));
} finally {
setLoading(false);
}
}, [profile]);
useEffect(() => {
void load();
}, [load]);
return { providers, loading, error, reload: () => void load() };
}
/**
* Renders the wizard when it is the first run. Calls `onDone` once the user
* finishes (so the host can drop the wizard and show the normal UI). Returns
@ -116,6 +189,8 @@ export function FirstRunWizard({
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
const modelServers = useModelServers();
const providerCatalog = useOpenCodeProviderCatalog();
const codexProviderCatalog = useCodexProviderCatalog();
const claudeProviderCatalog = useClaudeProviderCatalog();
if (vm.isFirstRun === null) return null;
if (!forceOpen && vm.isFirstRun === false) return null;
@ -184,6 +259,8 @@ export function FirstRunWizard({
entry={entry}
servers={modelServers.servers}
providerCatalog={providerCatalog}
codexProviderCatalog={codexProviderCatalog}
claudeProviderCatalog={claudeProviderCatalog}
onToggle={() => vm.toggle(entry.profile.id)}
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
onRemove={() => vm.remove(entry.profile.id)}
@ -215,6 +292,8 @@ function ProfileRow({
entry,
servers,
providerCatalog,
codexProviderCatalog,
claudeProviderCatalog,
onToggle,
onChange,
onRemove,
@ -225,6 +304,8 @@ function ProfileRow({
servers: LocalModelServerConfig[];
/** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */
providerCatalog: OpenCodeProviderCatalog;
codexProviderCatalog: CodexProviderCatalog;
claudeProviderCatalog: ClaudeProviderCatalog;
onToggle: () => void;
onChange: (p: AgentProfile) => void;
onRemove: () => void;
@ -329,6 +410,24 @@ function ProfileRow({
onChange={onChange}
/>
)}
{profile.structuredAdapter === "codex" &&
(selected || profile.codexProvider) && (
<CodexProviderFields
profile={profile}
catalog={codexProviderCatalog}
onChange={onChange}
/>
)}
{profile.structuredAdapter === "claude" &&
(selected || profile.claudeProvider) && (
<ClaudeProviderFields
profile={profile}
catalog={claudeProviderCatalog}
onChange={onChange}
/>
)}
</li>
);
}
@ -794,6 +893,433 @@ function OpenCodeProviderFields({
);
}
type SimpleProviderCatalogEntry =
| CodexProviderCatalogEntry
| ClaudeProviderCatalogEntry;
interface ProviderModelSecretFieldsProps {
engine: "Codex" | "Claude";
profile: AgentProfile;
catalog: {
providers: SimpleProviderCatalogEntry[] | null;
loading: boolean;
error: string | null;
reload: () => void;
};
existing:
| AgentProfile["codexProvider"]
| AgentProfile["claudeProvider"]
| undefined;
customSupported: boolean;
saveProfile: (input: {
providerId: string;
model: string;
apiKey: string;
custom?: CodexCustomProviderConfig;
}) => Promise<AgentProfile>;
onChange: (p: AgentProfile) => void;
}
function ProviderModelSecretFields({
engine,
profile,
catalog,
existing,
customSupported,
saveProfile,
onChange,
}: ProviderModelSecretFieldsProps) {
const [mode, setMode] = useState<"catalog" | "custom">(
existing && "custom" in existing && existing.custom ? "custom" : "catalog",
);
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
const [model, setModel] = useState(existing?.model ?? "");
const [providerFilter, setProviderFilter] = useState("");
const existingCustom =
existing && "custom" in existing ? existing.custom : undefined;
const [customBaseUrl, setCustomBaseUrl] = useState(existingCustom?.baseUrl ?? "");
const [customDisplayName, setCustomDisplayName] = useState(
existingCustom?.displayName ?? "",
);
const [apiKey, setApiKey] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
const isEditing = existing !== undefined;
const catalogReady = catalog.providers !== null && !catalog.loading;
const models =
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
const filteredProviders = (catalog.providers ?? []).filter((p) => {
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 ||
apiKey.length === 0 ||
(mode === "catalog" && (!catalogReady || Boolean(catalog.error)));
async function save() {
const errors: CloudFieldErrors = {};
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" && 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;
setSaving(true);
setSaveError(null);
try {
const saved = await saveProfile({
providerId: providerId.trim(),
model: model.trim(),
apiKey,
...(mode === "custom"
? {
custom: {
baseUrl: customBaseUrl.trim(),
displayName:
customDisplayName.trim().length > 0
? customDisplayName.trim()
: undefined,
},
}
: {}),
});
onChange(saved);
setApiKey("");
} catch (e) {
setSaveError(describeError(e));
} finally {
setSaving(false);
}
}
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider cloud ({engine})
</legend>
{saveError && (
<p role="alert" className="text-sm text-danger">
{saveError}
</p>
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
</Button>
</div>
)}
{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("");
} else {
setProviderId(v);
}
setModel("");
setFieldErrors((prev) => ({
...prev,
providerId: undefined,
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.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>
))}
{customSupported && (
<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>
</>
)}
{mode === "custom" && customSupported && (
<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>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 provider (optionnel)</Caption>
<Input
aria-label={`${profile.name} custom display name`}
value={customDisplayName}
placeholder="ex. Mon provider"
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
</fieldset>
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-…"
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
setApiKey(e.target.value);
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
}}
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
</IconButton>
</div>
{fieldErrors.apiKey && (
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
</small>
{isEditing && (
<small className="text-xs text-muted">
Le profil conserve sa référence de secret existante ; la clé n'est
jamais réaffichée côté UI.
</small>
)}
</label>
<Button
variant="primary"
size="sm"
aria-label={`Enregistrer ${engine}`}
loading={saving}
disabled={saveDisabled}
onClick={() => void save()}
className="w-fit"
>
Enregistrer
</Button>
</fieldset>
);
}
function CodexProviderFields({
profile,
catalog,
onChange,
}: {
profile: AgentProfile;
catalog: CodexProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { profile: profileGateway } = useGateways();
return (
<ProviderModelSecretFields
engine="Codex"
profile={profile}
catalog={catalog}
existing={profile.codexProvider}
customSupported={catalog.providers?.some((p) => p.customSupported) ?? false}
saveProfile={(input) =>
profileGateway.saveCodexProviderProfile({
profile,
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
custom: input.custom,
})
}
onChange={onChange}
/>
);
}
function ClaudeProviderFields({
profile,
catalog,
onChange,
}: {
profile: AgentProfile;
catalog: ClaudeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { profile: profileGateway } = useGateways();
return (
<ProviderModelSecretFields
engine="Claude"
profile={profile}
catalog={catalog}
existing={profile.claudeProvider}
customSupported={false}
saveProfile={(input) =>
profileGateway.saveClaudeProviderProfile({
profile,
providerId: input.providerId,
model: input.model,
apiKey: input.apiKey,
})
}
onChange={onChange}
/>
);
}
/**
* The OpenCode + llama.cpp config section: the base URL of the local
* `llama-server` (`/v1`), the model it serves, and an optional API key (the key

View File

@ -13,6 +13,9 @@ import type {
AgentDrift,
AgentProfile,
AppExitWorkGuardState,
ClaudeProviderCatalogEntry,
CodexCustomProviderConfig,
CodexProviderCatalogEntry,
CustomProviderConfig,
DomainEvent,
EmbedderEngines,
@ -691,6 +694,24 @@ export interface ProfileGateway {
saveOpenCodeProviderProfile(
input: SaveOpenCodeProviderProfileInput,
): Promise<AgentProfile>;
/** Static catalogue of Codex providers/models (ticket #99). */
listCodexProviders(): Promise<CodexProviderCatalogEntry[]>;
/**
* Creates or replaces (by id) a Codex profile with provider/model/secret
* config. The literal API key is sealed backend-side and never returned.
*/
saveCodexProviderProfile(
input: SaveCodexProviderProfileInput,
): Promise<AgentProfile>;
/** Static catalogue of Claude providers/models (ticket #99). */
listClaudeProviders(): Promise<ClaudeProviderCatalogEntry[]>;
/**
* Creates or replaces (by id) a Claude profile with provider/model/secret
* config. The literal API key is sealed backend-side and never returned.
*/
saveClaudeProviderProfile(
input: SaveClaudeProviderProfileInput,
): Promise<AgentProfile>;
}
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
@ -719,6 +740,32 @@ export interface SaveOpenCodeProviderProfileInput {
custom?: CustomProviderConfig;
}
/** Input for {@link ProfileGateway.saveCodexProviderProfile}. */
export interface SaveCodexProviderProfileInput {
/** The profile to create or replace (by id). */
profile: AgentProfile;
/** Provider id used as Codex's `model_provider`. */
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 endpoint config for a Codex provider. */
custom?: CodexCustomProviderConfig;
}
/** Input for {@link ProfileGateway.saveClaudeProviderProfile}. */
export interface SaveClaudeProviderProfileInput {
/** The profile to create or replace (by id). */
profile: AgentProfile;
/** Provider id. V1 backend exposes `"anthropic"`. */
providerId: string;
/** Model name served by this provider. */
model: string;
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
apiKey: string;
}
/**
* Local model servers (F35). CRUD over the global registry of declared
* `llama.cpp` servers an OpenCode profile can bind to via