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:
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user