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

@ -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();