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:
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user