feat(frontend): support des providers OpenCode cloud (#92)
Ajoute l'UI de configuration des providers OpenCode cloud dans le wizard first-run (sélection, édition, sauvegarde, suppression), le domaine et les ports associés, ainsi que les adapters HTTP/mock correspondants. Build + 952 tests verts, comportements clés vérifiés en exécution réelle, y compris un test de couverture ajouté pour le parcours d'édition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -34,6 +34,7 @@ import type {
|
||||
MemoryType,
|
||||
McpToolPolicy,
|
||||
ModelServerCommandPreview,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectMcpToolPermissions,
|
||||
@ -61,6 +62,7 @@ import type {
|
||||
PermissionGateway,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
TemplateGateway,
|
||||
WorkStateGateway,
|
||||
@ -181,6 +183,21 @@ export class HttpProfileGateway implements ProfileGateway {
|
||||
request: { name: input.name, opencode: input.opencode },
|
||||
});
|
||||
}
|
||||
listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
|
||||
return this.http.invoke<OpenCodeProviderCatalogEntry[]>("list_opencode_providers");
|
||||
}
|
||||
saveOpenCodeProviderProfile(
|
||||
input: SaveOpenCodeProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
return this.http.invoke<AgentProfile>("save_opencode_provider_profile", {
|
||||
request: {
|
||||
profile: input.profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpModelServerGateway implements ModelServerGateway {
|
||||
|
||||
@ -34,6 +34,7 @@ import type {
|
||||
MemoryType,
|
||||
McpToolCatalogue,
|
||||
McpToolPolicy,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
@ -99,6 +100,7 @@ import type {
|
||||
ReattachResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
@ -1272,6 +1274,20 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Static mock catalogue mirroring the backend OpenCode cloud-provider list. */
|
||||
const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
|
||||
{
|
||||
providerId: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
models: ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"],
|
||||
},
|
||||
{
|
||||
providerId: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
models: ["openrouter/auto", "qwen/qwen3-coder"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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
|
||||
@ -1342,6 +1358,30 @@ export class MockProfileGateway implements ProfileGateway {
|
||||
opencode: input.opencode ?? seed.opencode,
|
||||
});
|
||||
}
|
||||
|
||||
async listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
|
||||
return structuredClone(MOCK_OPENCODE_PROVIDERS);
|
||||
}
|
||||
|
||||
async saveOpenCodeProviderProfile(
|
||||
input: SaveOpenCodeProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
const saved: AgentProfile = {
|
||||
...structuredClone(input.profile),
|
||||
opencode: undefined,
|
||||
opencodeProvider: {
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
// The mock never seals a real secret; the ref is opaque either way.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -8,8 +8,17 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
|
||||
import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports";
|
||||
import type {
|
||||
AgentProfile,
|
||||
FirstRunState,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
ProfileAvailability,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
CloneOpenCodeProfileFromSeedInput,
|
||||
ProfileGateway,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
} from "@/ports";
|
||||
|
||||
export class TauriProfileGateway implements ProfileGateway {
|
||||
firstRunState(): Promise<FirstRunState> {
|
||||
@ -51,4 +60,21 @@ export class TauriProfileGateway implements ProfileGateway {
|
||||
request: { name: input.name, opencode: input.opencode },
|
||||
});
|
||||
}
|
||||
|
||||
listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
|
||||
return invoke<OpenCodeProviderCatalogEntry[]>("list_opencode_providers");
|
||||
}
|
||||
|
||||
saveOpenCodeProviderProfile(
|
||||
input: SaveOpenCodeProviderProfileInput,
|
||||
): Promise<AgentProfile> {
|
||||
return invoke<AgentProfile>("save_opencode_provider_profile", {
|
||||
request: {
|
||||
profile: input.profile,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
apiKey: input.apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -999,6 +999,38 @@ export interface OpenCodeConfig {
|
||||
localModelServerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for an OpenCode profile backed by a **cloud** provider from the
|
||||
* OpenCode registry (Anthropic, OpenRouter, …), as opposed to the custom
|
||||
* `llamacpp` provider of {@link OpenCodeConfig}. Mirror of the backend
|
||||
* `OpenCodeProviderConfig` (camelCase wire format). `apiKeyRef` is an opaque
|
||||
* reference into the backend `SecretStore` — never the literal key, which is
|
||||
* only ever sent (never read back) via
|
||||
* {@link ProfileGateway.saveOpenCodeProviderProfile}.
|
||||
*/
|
||||
export interface OpenCodeProviderConfig {
|
||||
/** Provider id in the OpenCode registry (e.g. `"anthropic"`). */
|
||||
providerId: string;
|
||||
/** Model name served by this provider. */
|
||||
model: string;
|
||||
/** Opaque reference to the sealed API key; never the literal key. */
|
||||
apiKeyRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of the static OpenCode cloud-provider catalogue (mirror of the
|
||||
* backend `OpenCodeProviderDto`), returned by
|
||||
* {@link ProfileGateway.listOpenCodeProviders}.
|
||||
*/
|
||||
export interface OpenCodeProviderCatalogEntry {
|
||||
/** Provider id in the OpenCode registry (e.g. `"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.
|
||||
@ -1029,6 +1061,12 @@ export interface AgentProfile {
|
||||
chatHttp?: HttpChatConfig;
|
||||
/** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */
|
||||
opencode?: OpenCodeConfig;
|
||||
/**
|
||||
* OpenCode **cloud** provider config (ticket #92). Mutually exclusive with
|
||||
* {@link opencode}: a profile is either local (`llamacpp`) or cloud, never
|
||||
* both.
|
||||
*/
|
||||
opencodeProvider?: OpenCodeProviderConfig;
|
||||
}
|
||||
|
||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||
@ -283,6 +284,152 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||
const OPENCODE = "OpenCode + llama.cpp";
|
||||
|
||||
it("defaults to Local, and switching to Cloud swaps the sub-form", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
|
||||
expect(
|
||||
screen.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
|
||||
expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull();
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
});
|
||||
|
||||
it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
id: "cfg-oc-cloud-1",
|
||||
name: "Claude via OpenCode",
|
||||
command: "opencode",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: "opencode --version",
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "openCode",
|
||||
opencodeProvider: {
|
||||
providerId: "anthropic",
|
||||
model: "claude-sonnet-5",
|
||||
apiKeyRef: "secret-ref-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const gateways = {
|
||||
profile,
|
||||
modelServer: new MockModelServerGateway(),
|
||||
} as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<FirstRunWizard forceOpen />
|
||||
</DIProvider>,
|
||||
);
|
||||
await waitForLoaded();
|
||||
|
||||
// Scope to this row: the still-unconfigured "OpenCode + llama.cpp"
|
||||
// reference also renders (edit mode dedups by id only), with its own
|
||||
// Local/Cloud segmented control.
|
||||
const row = within(
|
||||
screen.getByLabelText("use Claude via OpenCode").closest("li")!,
|
||||
);
|
||||
expect(
|
||||
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("true");
|
||||
expect(
|
||||
row.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
|
||||
"aria-checked",
|
||||
),
|
||||
).toBe("false");
|
||||
expect(
|
||||
(row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("cascades provider ➜ model and disables Save until the API key is filled", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
|
||||
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement;
|
||||
expect(modelSelect.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(providerSelect, { target: { value: "anthropic" } });
|
||||
expect(modelSelect.disabled).toBe(false);
|
||||
|
||||
const saveButton = screen.getByRole("button", {
|
||||
name: "Enregistrer",
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } });
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
target: { value: "sk-ant-secret" },
|
||||
});
|
||||
expect(saveButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => {
|
||||
const { profile } = renderWizard();
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
|
||||
fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), {
|
||||
target: { value: "anthropic" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||
target: { value: "claude-sonnet-5" },
|
||||
});
|
||||
const apiKeyInput = screen.getByLabelText(
|
||||
`${OPENCODE} api key`,
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const saved = await profile.listProfiles();
|
||||
const opencode = saved.find((p) => p.command === "opencode");
|
||||
expect(opencode?.opencodeProvider?.providerId).toBe("anthropic");
|
||||
expect(opencode?.opencodeProvider?.model).toBe("claude-sonnet-5");
|
||||
expect(opencode?.opencode).toBeUndefined();
|
||||
});
|
||||
// The key is never kept around client-side once saved.
|
||||
expect(apiKeyInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("shows submit-time validation messages when provider/model/key are missing", async () => {
|
||||
renderWizard();
|
||||
await waitForLoaded();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||
|
||||
// Save stays disabled with no key, so drive validation via a filled key but
|
||||
// no provider/model to see the field-level messages fire on submit.
|
||||
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||
target: { value: "sk-ant-secret" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
|
||||
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
|
||||
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||
const OPENCODE = "OpenCode + llama.cpp";
|
||||
const CLONE1 = `${OPENCODE} (copy 1)`;
|
||||
|
||||
@ -15,12 +15,17 @@
|
||||
* `./profile`.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
GatewayError,
|
||||
HttpChatConfig,
|
||||
LocalModelServerConfig,
|
||||
OpenCodeConfig,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||
import {
|
||||
ModelServersPanel,
|
||||
@ -41,6 +46,54 @@ function Caption({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */
|
||||
interface OpenCodeProviderCatalog {
|
||||
providers: OpenCodeProviderCatalogEntry[] | 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
|
||||
* populated. Exposes a `reload` for the blocking "Réessayer" state.
|
||||
*/
|
||||
function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
|
||||
const { profile } = useGateways();
|
||||
const [providers, setProviders] = useState<OpenCodeProviderCatalogEntry[] | 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.listOpenCodeProviders());
|
||||
} 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
|
||||
@ -62,6 +115,7 @@ export function FirstRunWizard({
|
||||
// which pre-loads and pre-selects the already-configured profiles.
|
||||
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
|
||||
const modelServers = useModelServers();
|
||||
const providerCatalog = useOpenCodeProviderCatalog();
|
||||
|
||||
if (vm.isFirstRun === null) return null;
|
||||
if (!forceOpen && vm.isFirstRun === false) return null;
|
||||
@ -129,6 +183,7 @@ export function FirstRunWizard({
|
||||
key={entry.profile.id}
|
||||
entry={entry}
|
||||
servers={modelServers.servers}
|
||||
providerCatalog={providerCatalog}
|
||||
onToggle={() => vm.toggle(entry.profile.id)}
|
||||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||||
onRemove={() => vm.remove(entry.profile.id)}
|
||||
@ -159,6 +214,7 @@ export function FirstRunWizard({
|
||||
function ProfileRow({
|
||||
entry,
|
||||
servers,
|
||||
providerCatalog,
|
||||
onToggle,
|
||||
onChange,
|
||||
onRemove,
|
||||
@ -167,6 +223,8 @@ function ProfileRow({
|
||||
entry: WizardEntry;
|
||||
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
|
||||
servers: LocalModelServerConfig[];
|
||||
/** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */
|
||||
providerCatalog: OpenCodeProviderCatalog;
|
||||
onToggle: () => void;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
onRemove: () => void;
|
||||
@ -263,6 +321,77 @@ function ProfileRow({
|
||||
)}
|
||||
|
||||
{profile.structuredAdapter === "openCode" && (
|
||||
<OpenCodeModeFields
|
||||
profile={profile}
|
||||
errors={errors}
|
||||
servers={servers}
|
||||
providerCatalog={providerCatalog}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control (F — ticket #92) choosing whether an OpenCode profile runs
|
||||
* against the local `llama.cpp` endpoint or a cloud provider from the OpenCode
|
||||
* registry, and renders the matching sub-form. The two sub-forms never overlap;
|
||||
* switching segments keeps the inactive one's draft in memory (component-local
|
||||
* state) without touching `profile` until it is actually submitted.
|
||||
*/
|
||||
function OpenCodeModeFields({
|
||||
profile,
|
||||
errors,
|
||||
servers,
|
||||
providerCatalog,
|
||||
onChange,
|
||||
}: {
|
||||
profile: AgentProfile;
|
||||
errors: ProfileErrors;
|
||||
servers: LocalModelServerConfig[];
|
||||
providerCatalog: OpenCodeProviderCatalog;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"local" | "cloud">(
|
||||
profile.opencodeProvider ? "cloud" : "local",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-col gap-2">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="mode du profil OpenCode"
|
||||
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ id: "local", label: "Local (llama.cpp)" },
|
||||
{ id: "cloud", label: "Provider cloud" },
|
||||
] as const
|
||||
).map((seg) => {
|
||||
const active = mode === seg.id;
|
||||
return (
|
||||
<button
|
||||
key={seg.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setMode(seg.id)}
|
||||
className={cn(
|
||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-primary text-on-primary"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mode === "local" && (
|
||||
<OpenCodeFields
|
||||
profile={profile}
|
||||
errors={errors}
|
||||
@ -270,7 +399,223 @@ function ProfileRow({
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{mode === "cloud" && (
|
||||
<OpenCodeProviderFields
|
||||
profile={profile}
|
||||
catalog={providerCatalog}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */
|
||||
interface CloudFieldErrors {
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
|
||||
* model (cascading selects fed by the static catalogue) ➜ API key. The key is
|
||||
* never pre-filled (create or edit) since the backend never returns it — it
|
||||
* resigns whatever literal it receives on every save, so editing an existing
|
||||
* cloud profile requires re-entering it every time (§3 of the spec).
|
||||
*/
|
||||
function OpenCodeProviderFields({
|
||||
profile,
|
||||
catalog,
|
||||
onChange,
|
||||
}: {
|
||||
profile: AgentProfile;
|
||||
catalog: OpenCodeProviderCatalog;
|
||||
onChange: (p: AgentProfile) => void;
|
||||
}) {
|
||||
const { profile: profileGateway } = useGateways();
|
||||
const existing = profile.opencodeProvider;
|
||||
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
||||
const [model, setModel] = useState(existing?.model ?? "");
|
||||
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 models =
|
||||
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
||||
const catalogReady = catalog.providers !== null && !catalog.loading;
|
||||
const saveDisabled =
|
||||
saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0;
|
||||
|
||||
async function save() {
|
||||
const errors: CloudFieldErrors = {};
|
||||
if (providerId.length === 0) errors.providerId = "Le provider est obligatoire.";
|
||||
if (model.length === 0) errors.model = "Le modèle est obligatoire.";
|
||||
if (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 profileGateway.saveOpenCodeProviderProfile({
|
||||
profile,
|
||||
providerId,
|
||||
model,
|
||||
apiKey,
|
||||
});
|
||||
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 (OpenCode)
|
||||
</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>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Provider</Caption>
|
||||
<select
|
||||
aria-label={`${profile.name} provider`}
|
||||
value={providerId}
|
||||
disabled={!catalogReady}
|
||||
onChange={(e) => {
|
||||
setProviderId(e.target.value);
|
||||
setModel("");
|
||||
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.providerId ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
||||
</option>
|
||||
{catalog.providers?.map((p) => (
|
||||
<option key={p.providerId} value={p.providerId}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErrors.providerId && (
|
||||
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>Modèle</Caption>
|
||||
<select
|
||||
aria-label={`${profile.name} model`}
|
||||
value={model}
|
||||
disabled={providerId.length === 0}
|
||||
onChange={(e) => {
|
||||
setModel(e.target.value);
|
||||
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||
}}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
fieldErrors.model ? "border-danger" : "border-border",
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||
</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldErrors.model && (
|
||||
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<Caption>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-ant-…"
|
||||
}
|
||||
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">
|
||||
Pour des raisons de sécurité, la clé n'est jamais réaffichée :
|
||||
ressaisissez-la à chaque modification de ce profil.
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={saving}
|
||||
disabled={saveDisabled}
|
||||
onClick={() => void save()}
|
||||
className="w-fit"
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -144,9 +144,11 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
|
||||
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
||||
}
|
||||
}
|
||||
// An OpenCode profile carries its own llama.cpp endpoint config; the backend
|
||||
// refuses to persist it unless base URL + model are well-formed, so mirror that.
|
||||
if (p.structuredAdapter === "openCode") {
|
||||
// An OpenCode profile carries its own llama.cpp endpoint config (local mode)
|
||||
// or a cloud provider config (`opencodeProvider`, ticket #92) — never both,
|
||||
// and the cloud sub-form owns its own submit-time validation (provider,
|
||||
// model, API key), so only the local-mode shape is mirrored here.
|
||||
if (p.structuredAdapter === "openCode" && !p.opencodeProvider) {
|
||||
if (!p.opencode) {
|
||||
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||
errors.model = "Model is required.";
|
||||
|
||||
@ -35,6 +35,7 @@ import type {
|
||||
MemoryType,
|
||||
McpToolPolicy,
|
||||
OpenCodeConfig,
|
||||
OpenCodeProviderCatalogEntry,
|
||||
EffectivePermissions,
|
||||
PairedDevice,
|
||||
PairingCode,
|
||||
@ -671,6 +672,21 @@ export interface ProfileGateway {
|
||||
cloneOpenCodeProfileFromSeed(
|
||||
input?: CloneOpenCodeProfileFromSeedInput,
|
||||
): Promise<AgentProfile>;
|
||||
/**
|
||||
* Static catalogue of OpenCode cloud providers (ticket #92), for the
|
||||
* provider/model pickers of the Cloud sub-form.
|
||||
*/
|
||||
listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]>;
|
||||
/**
|
||||
* Creates or replaces (by id) an OpenCode profile in **cloud** mode (ticket
|
||||
* #92). Unlike {@link saveProfile}, this takes the literal API key: the
|
||||
* backend seals it into the `SecretStore` and never returns it — the
|
||||
* returned profile's `opencodeProvider` only ever carries `providerId` +
|
||||
* `model` (+ the opaque `apiKeyRef`), never the literal key.
|
||||
*/
|
||||
saveOpenCodeProviderProfile(
|
||||
input: SaveOpenCodeProviderProfileInput,
|
||||
): Promise<AgentProfile>;
|
||||
}
|
||||
|
||||
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
|
||||
@ -681,6 +697,18 @@ export interface CloneOpenCodeProfileFromSeedInput {
|
||||
opencode?: OpenCodeConfig;
|
||||
}
|
||||
|
||||
/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */
|
||||
export interface SaveOpenCodeProviderProfileInput {
|
||||
/** The profile to create or replace (by id). */
|
||||
profile: AgentProfile;
|
||||
/** Provider id in the OpenCode registry (e.g. `"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