feat(opencode): remplace le provider Ollama par llama.cpp

Le tool-calling local ne fonctionnait jamais via Ollama. Refonte du
support local d'OpenCode autour de llama.cpp: profil, catalogue,
matérialisation de la config OpenCode et surface first-run alignés sur
llama-server (backend + frontend).

QA vert (commandes réelles): domain 244, application 81+64, infra 263
(10 échecs = bind-port sandbox identiques sur develop, non-régression),
frontend 574, tsc propre. Réserve E2E live non bloquante faute de
llama-server joignable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 00:41:19 +02:00
parent eaba05d27d
commit 4e70631c40
12 changed files with 478 additions and 140 deletions

View File

@ -1160,20 +1160,18 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
cwdTemplate: "{projectRoot}",
},
{
id: "mock-ollama",
name: "Ollama / OpenAI-compatible local model",
command: "openai-compatible",
id: "mock-opencode",
name: "OpenCode + llama.cpp",
command: "opencode",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
detect: "opencode --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "openAiCompatible",
chatHttp: {
endpoint: "http://localhost:11434/v1",
model: "qwen2.5-coder",
requestTimeoutMs: 120_000,
connectTimeoutMs: 5_000,
maxToolIterations: 16,
structuredAdapter: "openCode",
opencode: {
baseURL: "http://localhost:8080/v1",
apiKey: "sk-no-key",
model: "qwen3-coder-30b",
},
},
];

View File

@ -23,15 +23,15 @@ function customProfile(id: string, command: string): AgentProfile {
describe("MockProfileGateway", () => {
it("firstRunState is first-run with only the selectable reference profiles", async () => {
// §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs
// + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are
// filtered out of the selection path server-side.
// + the OpenCode + llama.cpp local adapter); Gemini/Aider are filtered out of
// the selection path server-side.
const gw = new MockProfileGateway();
const state = await gw.firstRunState();
expect(state.isFirstRun).toBe(true);
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
"claude",
"codex",
"openai-compatible",
"opencode",
]);
});
@ -53,7 +53,7 @@ describe("MockProfileGateway", () => {
expect(byCommand).toEqual({
claude: true,
codex: false,
"openai-compatible": false,
opencode: false,
});
});

View File

@ -662,10 +662,31 @@ export interface HttpChatConfig {
maxToolIterations?: number;
}
/** Configuration for an OpenCode process-backed profile. */
/**
* Configuration for an OpenCode process-backed profile pointed at its own
* llama.cpp (OpenAI-compatible) endpoint. Mirror of the backend `OpenCodeConfig`
* (camelCase wire format: `baseURL`, `apiKey?`, `model`, `reasoning?`,
* `attachment?`). OpenCode is a locally-piloted CLI, distinct from the in-process
* {@link HttpChatConfig} adapter.
*/
export interface OpenCodeConfig {
/** Full OpenCode model id, for example `ollama/qwen3-coder:30b`. */
model?: string;
/**
* OpenAI-compatible base URL served by `llama-server` (http/https), for
* example `http://localhost:8080/v1`.
*/
baseURL: string;
/**
* Optional API key forwarded to the provider — the key *itself*, not an env
* var name (a local llama.cpp typically needs none / a placeholder). Omitted
* when blank (mirrors the backend `skip_serializing_if`).
*/
apiKey?: string;
/** Model name served by `llama-server`, for example `qwen3-coder-30b`. */
model: string;
/** Enables model reasoning. Effective default: `true`. */
reasoning?: boolean;
/** Enables attachments. Effective default: `false`. */
attachment?: boolean;
}
/**

View File

@ -176,130 +176,103 @@ describe("FirstRunWizard (with MockProfileGateway)", () => {
});
});
describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => {
const OLLAMA = "Ollama / OpenAI-compatible local model";
describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
const OPENCODE = "OpenCode + llama.cpp";
it("offers the local model as a selectable, pre-filled HTTP config row", async () => {
it("offers the local model as a selectable, pre-filled OpenCode config row", async () => {
renderWizard();
await waitForLoaded();
expect(screen.getByText(OLLAMA)).toBeTruthy();
// The structured HTTP fields are rendered, pre-filled from the reference.
expect(screen.getByText(OPENCODE)).toBeTruthy();
// The OpenCode fields are rendered, pre-filled from the reference.
expect(
(screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value,
).toBe("http://localhost:11434/v1");
(screen.getByLabelText(`${OPENCODE} base url`) as HTMLInputElement).value,
).toBe("http://localhost:8080/v1");
expect(
(screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value,
).toBe("qwen2.5-coder");
// Claude/Codex rows must NOT grow HTTP fields (additive, no regression).
expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull();
(screen.getByLabelText(`${OPENCODE} model`) as HTMLInputElement).value,
).toBe("qwen3-coder-30b");
// Claude/Codex rows must NOT grow endpoint fields (additive, no regression).
expect(screen.queryByLabelText("Claude Code base url")).toBeNull();
});
it("labels the API key field as an env var NAME and flags a raw key", async () => {
it("exposes the API key as a free-form optional field (not an env var name)", async () => {
renderWizard();
await waitForLoaded();
const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement;
// The label/placeholder make the env-var-name intent explicit.
expect(apiKey.placeholder.toLowerCase()).toContain("variable name");
const apiKey = screen.getByLabelText(`${OPENCODE} api key`) as HTMLInputElement;
expect(apiKey.value).toBe("sk-no-key");
// A raw secret is not a valid env var identifier ⇒ inline error.
// A raw secret is accepted verbatim (OpenCode carries the key itself) — no
// env-var-name error appears.
fireEvent.change(apiKey, { target: { value: "sk-secret-123" } });
expect(apiKey.value).toBe("sk-secret-123");
expect(
screen.getByText(/valid env var name \(not the key itself\)/i),
).toBeTruthy();
// A proper env var name clears the error.
fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } });
expect(
screen.queryByText(/valid env var name \(not the key itself\)/i),
screen.queryByText(/valid env var name/i),
).toBeNull();
});
it("flags an invalid endpoint scheme inline", async () => {
it("flags an invalid base URL scheme inline", async () => {
renderWizard();
await waitForLoaded();
const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`);
fireEvent.change(endpoint, { target: { value: "ftp://oops" } });
const baseUrl = screen.getByLabelText(`${OPENCODE} base url`);
fireEvent.change(baseUrl, { target: { value: "ftp://oops" } });
expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy();
});
it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => {
it("flags an empty model inline", async () => {
renderWizard();
await waitForLoaded();
expect(
(screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value,
).toBe("120000");
expect(
(screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value,
).toBe("5000");
expect(
(screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement)
.value,
).toBe("16");
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "" },
});
expect(screen.getByText(/model is required/i)).toBeTruthy();
});
it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => {
it("persists the edited OpenCode config round-trip when selected and saved", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
target: { value: "90000" },
// Select the local model and edit its base URL, model and API key.
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} base url`), {
target: { value: "http://localhost:9090/v1" },
});
fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), {
target: { value: "3000" },
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "qwen3-coder-14b" },
});
fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), {
target: { value: "8" },
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
target: { value: "sk-local" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const ollama = saved.find((p) => p.command === "openai-compatible");
expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000);
expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000);
expect(ollama?.chatHttp?.maxToolIterations).toBe(8);
const opencode = saved.find((p) => p.command === "opencode");
expect(opencode?.structuredAdapter).toBe("openCode");
expect(opencode?.opencode?.baseURL).toBe("http://localhost:9090/v1");
expect(opencode?.opencode?.model).toBe("qwen3-coder-14b");
expect(opencode?.opencode?.apiKey).toBe("sk-local");
});
});
it("flags a zero timeout inline (mirror of the backend guard)", async () => {
renderWizard();
await waitForLoaded();
fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), {
target: { value: "0" },
});
expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy();
});
it("persists the edited HTTP config round-trip when selected and saved", async () => {
it("clearing the API key drops it (optional, omitted when blank)", async () => {
const { profile } = renderWizard();
await waitForLoaded();
// Select the local model and edit its model name.
fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`));
fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), {
target: { value: "qwen3" },
});
fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), {
target: { value: "LAN_KEY" },
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const ollama = saved.find((p) => p.command === "openai-compatible");
expect(ollama?.structuredAdapter).toBe("openAiCompatible");
expect(ollama?.chatHttp?.model).toBe("qwen3");
expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY");
// The endpoint round-trips untouched.
expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1");
const opencode = saved.find((p) => p.command === "opencode");
expect(opencode?.opencode?.apiKey).toBeUndefined();
});
});
});

View File

@ -15,11 +15,12 @@
* `./profile`.
*/
import type { AgentProfile, HttpChatConfig } from "@/domain";
import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultHttpChatConfig,
defaultOpenCodeConfig,
parseArgs,
validateProfile,
type ProfileErrors,
@ -190,10 +191,81 @@ function ProfileRow({
{profile.structuredAdapter === "openAiCompatible" && (
<HttpChatFields profile={profile} errors={errors} onChange={onChange} />
)}
{profile.structuredAdapter === "openCode" && (
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
)}
</li>
);
}
/**
* 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
* itself — a local llama.cpp usually needs none). Rendered only for a `openCode`
* profile; edits flow back through `onChange` as a patched `opencode`.
*/
function OpenCodeFields({
profile,
errors,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
onChange: (p: AgentProfile) => void;
}) {
const opencode = profile.opencode ?? defaultOpenCodeConfig();
const patch = (next: Partial<OpenCodeConfig>) =>
onChange({ ...profile, opencode: { ...opencode, ...next } });
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">
Local model (OpenCode + llama.cpp)
</legend>
<label className="flex flex-col gap-1">
<Caption>Base URL</Caption>
<Input
aria-label={`${profile.name} base url`}
value={opencode.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
onChange={(e) => patch({ baseURL: e.target.value })}
/>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Input
aria-label={`${profile.name} model`}
value={opencode.model}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.model)}
onChange={(e) => patch({ model: e.target.value })}
/>
{errors.model && <small className="text-xs text-danger">{errors.model}</small>}
</label>
<label className="flex flex-col gap-1">
<Caption>API key (optional a local llama.cpp needs none)</Caption>
<Input
aria-label={`${profile.name} api key`}
value={opencode.apiKey ?? ""}
placeholder="e.g. sk-no-key"
onChange={(e) => {
const v = e.target.value;
patch({ apiKey: v.length === 0 ? undefined : v });
}}
/>
</label>
</fieldset>
);
}
/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */
function parseOptInt(raw: string): number | undefined {
const trimmed = raw.trim();

View File

@ -9,6 +9,7 @@ import type { AgentProfile } from "@/domain";
import {
defaultHttpChatConfig,
defaultInjection,
defaultOpenCodeConfig,
emptyCustomProfile,
isProfileValid,
isRelativeSafe,
@ -16,6 +17,7 @@ import {
isValidHttpUrl,
parseArgs,
validateHttpChatConfig,
validateOpenCodeConfig,
validateProfile,
} from "./profile";
@ -189,6 +191,58 @@ describe("validateProfile for openAiCompatible profiles", () => {
});
});
describe("validateOpenCodeConfig", () => {
it("a well-formed llama.cpp config is valid", () => {
expect(validateOpenCodeConfig(defaultOpenCodeConfig())).toEqual({});
});
it("reports a bad base URL scheme and an empty model", () => {
const errors = validateOpenCodeConfig({ baseURL: "ftp://x", model: " " });
expect(errors.baseURL).toBeDefined();
expect(errors.model).toBeDefined();
});
it("an optional API key is free-form (no env-var-name constraint)", () => {
// Unlike the openAiCompatible adapter, OpenCode carries the key itself.
expect(
validateOpenCodeConfig({
baseURL: "http://localhost:8080/v1",
apiKey: "sk-secret-123",
model: "qwen3-coder-30b",
}),
).toEqual({});
});
});
describe("validateProfile for openCode profiles", () => {
function opencode(overrides: Partial<AgentProfile> = {}): AgentProfile {
return base({
name: "OpenCode + llama.cpp",
command: "opencode",
structuredAdapter: "openCode",
opencode: defaultOpenCodeConfig(),
...overrides,
});
}
it("the reference llama.cpp profile is valid", () => {
expect(validateProfile(opencode())).toEqual({});
expect(isProfileValid(opencode())).toBe(true);
});
it("surfaces opencode field errors through the profile validator", () => {
const errors = validateProfile(
opencode({ opencode: { baseURL: "nope", model: "" } }),
);
expect(errors.baseURL).toBeDefined();
expect(errors.model).toBeDefined();
expect(
isProfileValid(opencode({ opencode: { baseURL: "nope", model: "m" } })),
).toBe(false);
});
it("a missing opencode config on an openCode profile is invalid", () => {
const errors = validateProfile(opencode({ opencode: undefined }));
expect(errors.baseURL).toBeDefined();
expect(errors.model).toBeDefined();
});
});
describe("defaultInjection", () => {
it("produces a sensible default per strategy", () => {
expect(defaultInjection("conventionFile")).toEqual({

View File

@ -9,6 +9,7 @@ import type {
ContextInjection,
HttpChatConfig,
InjectionStrategy,
OpenCodeConfig,
} from "@/domain";
/** A field-keyed validation error map (empty ⇒ valid). */
@ -20,6 +21,7 @@ export type ProfileErrors = Partial<
| "flag"
| "var"
| "endpoint"
| "baseURL"
| "model"
| "apiKeyEnv"
| "requestTimeoutMs"
@ -95,6 +97,23 @@ export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors {
return errors;
}
/**
* Validates an {@link OpenCodeConfig} draft the way the backend
* `OpenCodeConfig::new` would (base URL http/https + non-empty, model non-empty;
* the API key is a free-form, optional secret). Returns an empty object when the
* draft is valid.
*/
export function validateOpenCodeConfig(c: OpenCodeConfig): ProfileErrors {
const errors: ProfileErrors = {};
if (!isValidHttpUrl(c.baseURL)) {
errors.baseURL = "Base URL must start with http:// or https://.";
}
if (c.model.trim().length === 0) {
errors.model = "Model is required.";
}
return errors;
}
/**
* Validates a profile draft the way the backend would, so the wizard can surface
* errors before any `invoke`. Returns an empty object when the draft is valid.
@ -125,8 +144,15 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
}
}
if (p.structuredAdapter === "openCode" && p.opencode?.model !== undefined) {
if (p.opencode.model.trim().length === 0) errors.model = "Model is required.";
// 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") {
if (!p.opencode) {
errors.baseURL = "Base URL must start with http:// or https://.";
errors.model = "Model is required.";
} else {
Object.assign(errors, validateOpenCodeConfig(p.opencode));
}
}
return errors;
}
@ -168,6 +194,19 @@ export function defaultHttpChatConfig(): HttpChatConfig {
};
}
/**
* A sensible default {@link OpenCodeConfig} for a fresh OpenCode profile draft —
* pre-fills a local `llama-server` endpoint so the form starts valid. Mirrors the
* backend reference profile (`http://localhost:8080/v1`, `qwen3-coder-30b`).
*/
export function defaultOpenCodeConfig(): OpenCodeConfig {
return {
baseURL: "http://localhost:8080/v1",
apiKey: "sk-no-key",
model: "qwen3-coder-30b",
};
}
/** A fresh, empty custom-profile draft (id minted client-side). */
export function emptyCustomProfile(): AgentProfile {
return {