feat: finalise ticket99 - implémentation agent model configuration v2
- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
This commit is contained in:
@ -15,8 +15,6 @@ import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexProviderCatalogEntry,
|
||||
EffectivePermissions,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
@ -67,8 +65,6 @@ import type {
|
||||
PermissionGateway,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
TemplateGateway,
|
||||
@ -206,37 +202,6 @@ 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,8 +9,6 @@ import type {
|
||||
AgentDrift,
|
||||
AppExitWorkGuardState,
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexProviderCatalogEntry,
|
||||
DiagnosticWarning,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
@ -105,8 +103,6 @@ import type {
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
@ -1297,25 +1293,6 @@ 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
|
||||
@ -1412,52 +1389,6 @@ export class MockProfileGateway implements ProfileGateway {
|
||||
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,8 +10,6 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexProviderCatalogEntry,
|
||||
FirstRunState,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileAvailability,
|
||||
@ -19,8 +17,6 @@ import type {
|
||||
import type {
|
||||
CloneOpenCodeProfileFromSeedInput,
|
||||
ProfileGateway,
|
||||
SaveClaudeProviderProfileInput,
|
||||
SaveCodexProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
} from "@/ports";
|
||||
|
||||
@ -83,38 +79,4 @@ 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,36 +1068,6 @@ 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
|
||||
@ -1112,18 +1082,6 @@ 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
|
||||
@ -1138,28 +1096,6 @@ 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.
|
||||
@ -1196,10 +1132,11 @@ export interface AgentProfile {
|
||||
* both.
|
||||
*/
|
||||
opencodeProvider?: OpenCodeProviderConfig;
|
||||
/** Codex provider/model config (ticket #99). */
|
||||
codexProvider?: CodexProviderConfig;
|
||||
/** Claude provider/model config (ticket #99). */
|
||||
claudeProvider?: ClaudeProviderConfig;
|
||||
/**
|
||||
* Optional direct CLI model setting for Codex/Claude. `undefined` keeps the
|
||||
* CLI's own default. OpenCode keeps its dedicated provider/local model fields.
|
||||
*/
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||
|
||||
@ -183,47 +183,41 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — Codex/Claude provider configuration (ticket #99)", () => {
|
||||
it("saves a Codex provider profile and clears the literal key", async () => {
|
||||
describe("FirstRunWizard — Codex/Claude model configuration (ticket #99)", () => {
|
||||
it("shows only a free-form model field for Codex and persists no provider/API key", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
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);
|
||||
expect(row.queryByLabelText("OpenAI Codex CLI provider")).toBeNull();
|
||||
expect(row.queryByLabelText("OpenAI Codex CLI provider search")).toBeNull();
|
||||
expect(row.queryByLabelText("OpenAI Codex CLI api key")).toBeNull();
|
||||
|
||||
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.change(row.getByLabelText("OpenAI Codex CLI model"), {
|
||||
target: { value: "gpt-5-codex" },
|
||||
});
|
||||
|
||||
fireEvent.click(row.getByRole("button", { name: "Enregistrer Codex" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
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(codex?.model).toBe("gpt-5-codex");
|
||||
expect(JSON.stringify(codex)).not.toContain("provider");
|
||||
expect(JSON.stringify(codex)).not.toContain("apiKey");
|
||||
});
|
||||
expect((row.getByLabelText("OpenAI Codex CLI api key") as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("saves a Claude provider profile without exposing the literal key", async () => {
|
||||
it("shows only a free-form model field for Claude and persists no provider/API key", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
@ -233,31 +227,26 @@ describe("FirstRunWizard — Codex/Claude provider configuration (ticket #99)",
|
||||
if (!claudeToggle.checked) fireEvent.click(claudeToggle);
|
||||
|
||||
const row = within(claudeToggle.closest("li")!);
|
||||
fireEvent.change(await row.findByLabelText("Claude Code provider"), {
|
||||
target: { value: "anthropic" },
|
||||
});
|
||||
expect(row.queryByLabelText("Claude Code provider")).toBeNull();
|
||||
expect(row.queryByLabelText("Claude Code provider search")).toBeNull();
|
||||
expect(row.queryByLabelText("Claude Code api key")).toBeNull();
|
||||
|
||||
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" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
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(claude?.model).toBe("claude-sonnet-4-5");
|
||||
expect(JSON.stringify(claude)).not.toContain("provider");
|
||||
expect(JSON.stringify(claude)).not.toContain("apiKey");
|
||||
});
|
||||
expect(apiKey.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps an existing Codex SecretRef when editing provider/model", async () => {
|
||||
it("edits an existing Codex model without rendering provider/API key fields", async () => {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
@ -269,11 +258,7 @@ describe("FirstRunWizard — Codex/Claude provider configuration (ticket #99)",
|
||||
detect: "codex --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "codex",
|
||||
codexProvider: {
|
||||
providerId: "openai",
|
||||
model: "gpt-5-mini",
|
||||
apiKeyRef: "existing-secret-ref",
|
||||
},
|
||||
model: "gpt-5-mini",
|
||||
},
|
||||
]);
|
||||
const gateways = {
|
||||
@ -290,20 +275,22 @@ describe("FirstRunWizard — Codex/Claude provider configuration (ticket #99)",
|
||||
const row = within(
|
||||
screen.getByLabelText("use Codex configured").closest("li")!,
|
||||
);
|
||||
expect((row.getByLabelText("Codex configured api key") as HTMLInputElement).value).toBe("");
|
||||
expect(row.queryByLabelText("Codex configured provider")).toBeNull();
|
||||
expect(row.queryByLabelText("Codex configured api key")).toBeNull();
|
||||
expect(
|
||||
(row.getByLabelText("Codex configured model") as HTMLInputElement).value,
|
||||
).toBe("gpt-5-mini");
|
||||
|
||||
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" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
|
||||
|
||||
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");
|
||||
expect(saved.model).toBe("gpt-5");
|
||||
expect(JSON.stringify(saved)).not.toContain("provider");
|
||||
expect(JSON.stringify(saved)).not.toContain("apiKey");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,9 +19,6 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexCustomProviderConfig,
|
||||
CodexProviderCatalogEntry,
|
||||
GatewayError,
|
||||
HttpChatConfig,
|
||||
LocalModelServerConfig,
|
||||
@ -64,20 +61,6 @@ 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
|
||||
@ -111,62 +94,6 @@ 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
|
||||
@ -189,8 +116,6 @@ 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;
|
||||
@ -259,8 +184,6 @@ 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)}
|
||||
@ -292,8 +215,6 @@ function ProfileRow({
|
||||
entry,
|
||||
servers,
|
||||
providerCatalog,
|
||||
codexProviderCatalog,
|
||||
claudeProviderCatalog,
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
@ -304,8 +225,6 @@ 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;
|
||||
@ -411,22 +330,10 @@ function ProfileRow({
|
||||
/>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
{(profile.structuredAdapter === "codex" ||
|
||||
profile.structuredAdapter === "claude") &&
|
||||
(selected || profile.model) && (
|
||||
<CliModelField profile={profile} onChange={onChange} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
@ -893,430 +800,32 @@ 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,
|
||||
function CliModelField({
|
||||
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}
|
||||
/>
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<Input
|
||||
aria-label={`${profile.name} model`}
|
||||
value={profile.model ?? ""}
|
||||
placeholder="Laisser vide pour le modèle par défaut de la CLI"
|
||||
onChange={(e) => {
|
||||
const model = e.target.value.trim();
|
||||
onChange({
|
||||
...profile,
|
||||
model: model.length > 0 ? model : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<small className="text-xs text-faint">
|
||||
Optionnel. L'authentification Codex/Claude reste gérée par la CLI.
|
||||
</small>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -13,9 +13,6 @@ import type {
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
AppExitWorkGuardState,
|
||||
ClaudeProviderCatalogEntry,
|
||||
CodexCustomProviderConfig,
|
||||
CodexProviderCatalogEntry,
|
||||
CustomProviderConfig,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
@ -694,24 +691,6 @@ 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}. */
|
||||
@ -740,32 +719,6 @@ 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