From 65baf1bf12fc76626b3730b5e0dbe098632fb046 Mon Sep 17 00:00:00 2001
From: Alexandre Tostivint <53527080+atostivint@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:05:52 +0100
Subject: [PATCH] Add OpenCode Go as an AI provider
Use OpenCode's Go OpenAI-compatible endpoint with a Go or Go Plus API key. Send Dossier's user agent and a stable session header, list models from the public Go catalog, use prompt-based JSON for models that reject response_format, and explain Go's plan and region errors.
Adds the OPENCODE_GO enum value and its migration.
---
.env.example | 2 +-
DEVELOPMENT.md | 4 +-
PRIVACY.md | 2 +-
README.md | 2 +-
.../migration.sql | 2 +
prisma/schema.prisma | 2 +
site/index.html | 1 +
.../settings/ai-provider-section.test.tsx | 17 +++
src/lib/ai/config.test.ts | 13 ++
src/lib/ai/config.ts | 7 +-
src/lib/ai/index.ts | 9 +-
src/lib/ai/provider-options.ts | 16 +++
src/lib/ai/providers.live.test.ts | 31 ++++-
src/lib/ai/providers.test.ts | 131 ++++++++++++++++++
src/lib/ai/providers/openai-compatible.ts | 18 ++-
src/lib/ai/providers/opencode-go.ts | 127 +++++++++++++++++
src/lib/ai/structured-output.test.ts | 8 ++
src/lib/ai/test-live.ts | 10 ++
src/lib/env.test.ts | 2 +-
src/lib/env.ts | 2 +-
src/lib/jobs/refine.ts | 8 +-
21 files changed, 398 insertions(+), 16 deletions(-)
create mode 100644 prisma/migrations/20261027090000_ai_provider_opencode_go/migration.sql
create mode 100644 src/lib/ai/providers/opencode-go.ts
diff --git a/.env.example b/.env.example
index 46e9a931..4282d41c 100644
--- a/.env.example
+++ b/.env.example
@@ -36,7 +36,7 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dossier"
# Defaults for Settings → AI provider; values saved in the app override them.
# Provider: local (Ollama or llama.cpp), openai (any OpenAI-compatible
-# endpoint), anthropic, mistral or lmstudio.
+# endpoint), anthropic, mistral, lmstudio or opencode-go.
# DOSSIER_AI_PROVIDER=local
# DOSSIER_AI_URL=http://localhost:11434
# DOSSIER_AI_MODEL=llama3.1:8b
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 7fe7f609..3d88551d 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -135,7 +135,7 @@ Failed sign-ins are rate-limited in Postgres: after 5 failures within a minute f
## AI provider
-Dossier reads job postings and writes drafts with the model you choose in Settings → AI provider: a local model through Ollama or llama.cpp, LM Studio, any OpenAI-compatible server, Anthropic or Mistral. Until you save a choice there, the `DOSSIER_AI_*` variables decide (see `.env.example`). API keys are stored encrypted with a key derived from `DOSSIER_SECRET`, and never sent back to the browser.
+Dossier reads job postings and writes drafts with the model you choose in Settings → AI provider: a local model through Ollama or llama.cpp, LM Studio, any OpenAI-compatible server, Anthropic, Mistral or OpenCode Go. Until you save a choice there, the `DOSSIER_AI_*` variables decide (see `.env.example`). API keys are stored encrypted with a key derived from `DOSSIER_SECRET`, and never sent back to the browser.
Long AI tasks run as background jobs (`src/lib/jobs`): the page follows their named steps as they happen and picks them up again after a reload. A job's input and result hold personal data, so they don't last: a job that works on an application names it (`startJob(…, { applicationId })`) and is deleted with it, and finished jobs are deleted once they're older than **Keep task data for** in Settings → Your data (`src/lib/jobs/retention.ts`). Keep nothing in a job that has to outlast that; Undo on a toast and a page reattaching after a reload are fine. The prompts live in `src/lib/ai/tasks`; in strict mode ("Never invent experience", on by default) a fact guard flags names and numbers that aren't in the profile or the posting.
@@ -156,6 +156,8 @@ docker compose exec -e AI_LIVE=ollama app yarn vitest run src/lib/ai/providers.l
`AI_LIVE=mistral` checks Mistral instead, with the key saved in Settings → AI provider while Mistral is chosen there, or with `DOSSIER_AI_KEY` (and `DOSSIER_AI_MODEL`) passed with `-e`.
+`AI_LIVE=opencode-go` checks OpenCode Go, with the key saved in Settings while OpenCode Go is chosen there, or with `-e OPENCODE_TEST_KEY=…` (and `OPENCODE_TEST_MODEL`, by default `glm-5.3-flash`). It spends usage from that Go plan.
+
`AI_LIVE=lmstudio` checks LM Studio, with the model saved in Settings while LM Studio is chosen there, or with `-e DOSSIER_AI_MODEL=…` (and `DOSSIER_AI_URL`, by default `http://host.docker.internal:1234`). A model that isn't loaded gets loaded and stays loaded until you eject it.
## Job search
diff --git a/PRIVACY.md b/PRIVACY.md
index 9c935150..6f906694 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -38,7 +38,7 @@ Exports you download and database dumps you take are copies outside Dossier's re
| Who receives it | When | What |
|---|---|---|
-| A hosted AI provider (OpenAI-compatible, Anthropic or Mistral) | Each AI task, while one is chosen in Settings → AI provider | See [below](#what-a-hosted-ai-provider-receives) |
+| A hosted AI provider (OpenAI-compatible, Anthropic, Mistral or OpenCode Go) | Each AI task, while one is chosen in Settings → AI provider | See [below](#what-a-hosted-ai-provider-receives) |
| Google | While a Google account is connected (Settings → Google) | Signing in: your OAuth client's ID and secret, and the tokens. **Gmail:** the application emails and answers you send, with their PDFs, and a request for new mail on each check (every 10 minutes by default). **Calendar:** the next 14 days, to find your free times; each interview you add, change or remove: its time and a title with the role and company; and requests for the events in the months you look at in the dashboard's calendar, which Dossier shows and doesn't keep. Google already holds your mail and calendar; Dossier only asks for the permissions you give |
| The job source you chose (JSearch, Adzuna or the Bundesagentur für Arbeit) | Each search in Find jobs, which is off until you choose a source, and the job agent's search, at most three times in 24 hours while it's on (never on the Bundesagentur für Arbeit) | What you search for: keywords, place, country, radius and filters. Nothing from your profile: it only fills in the form. The Bundesagentur für Arbeit has no official API: Dossier uses the access its own job search uses, without its permission, so it's searched only after you tick "I understand" under that note, and `DOSSIER_JOBS_ARBEITSAGENTUR=off` takes it out |
| Hacker News (its official API, and Algolia's search of it) | When you press **Refresh** in Startups, then once a day while you open Startups at least every 14 days | Requests for YC startups' job posts and this month's “Who is hiring?” thread. Nothing about you: which posts suit your city and country is worked out on your server |
diff --git a/README.md b/README.md
index 502e44fc..593162ff 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
Dossier keeps your career history in one profile and turns it into a clean resume. For every job you apply to, it reads the posting, shows how you match, and drafts a tailored resume and cover letter that only use facts from your profile. You refine them in a chat, download them as PDFs and track each application from draft to offer.
-It's for one person on a small server: one password, your data in your own PostgreSQL database, and an AI model of your choice. That can be a local model through Ollama or LM Studio, so your profile and documents stay on your own hardware, or a hosted one (any OpenAI-compatible API, Anthropic or Mistral), which then receives what it writes from. [Privacy](https://github.com/AienTech/dossier/wiki/Privacy) says what goes where.
+It's for one person on a small server: one password, your data in your own PostgreSQL database, and an AI model of your choice. That can be a local model through Ollama or LM Studio, so your profile and documents stay on your own hardware, or a hosted one (any OpenAI-compatible API, Anthropic, Mistral or OpenCode Go), which then receives what it writes from. [Privacy](https://github.com/AienTech/dossier/wiki/Privacy) says what goes where.
**Get started:** [run it on your computer](#run-it-on-your-computer) in a few minutes, or [self-host it](#self-hosting) on a server. The [installation guide](https://github.com/AienTech/dossier/wiki) covers every way: Docker with or without Compose, Node.js, a plain Ubuntu server, CapRover, Coolify and Dokploy.
diff --git a/prisma/migrations/20261027090000_ai_provider_opencode_go/migration.sql b/prisma/migrations/20261027090000_ai_provider_opencode_go/migration.sql
new file mode 100644
index 00000000..6b719ed4
--- /dev/null
+++ b/prisma/migrations/20261027090000_ai_provider_opencode_go/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "AiProvider" ADD VALUE 'OPENCODE_GO';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 12c5fba5..0f0c1ab9 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -306,6 +306,8 @@ enum AiProvider {
MISTRAL
/// LM Studio on the user's own hardware (#210).
LMSTUDIO
+ /// OpenCode Go, through its OpenAI-compatible Go endpoint.
+ OPENCODE_GO
}
enum Creativity {
diff --git a/site/index.html b/site/index.html
index e0b8829e..8aad0d5b 100644
--- a/site/index.html
+++ b/site/index.html
@@ -162,6 +162,7 @@
Your job search, on your own server.
Ollama
LM Studio
OpenAI-compatible APIs
+ OpenCode Go
Anthropic
Mistral
diff --git a/src/app/(app)/settings/ai-provider-section.test.tsx b/src/app/(app)/settings/ai-provider-section.test.tsx
index af363a6c..68124d33 100644
--- a/src/app/(app)/settings/ai-provider-section.test.tsx
+++ b/src/app/(app)/settings/ai-provider-section.test.tsx
@@ -32,6 +32,7 @@ describe("AiProviderSection", () => {
"Local model",
"LM Studio",
"OpenAI-compatible",
+ "OpenCode Go",
"Anthropic",
"Mistral",
]);
@@ -55,6 +56,22 @@ describe("AiProviderSection", () => {
);
});
+ it("fills in OpenCode Go's address and model, with its key and privacy note", async () => {
+ render();
+ await userEvent.click(screen.getByRole("radio", { name: "OpenCode Go" }));
+ expect(screen.getByLabelText("Server address")).toHaveValue("https://opencode.ai/inference/go");
+ expect(screen.getByRole("note")).toHaveTextContent(/^OpenCode receives what each task needs: .* depend on your account with OpenCode\.$/);
+ expect(screen.getByLabelText("OpenCode API key")).toBeInTheDocument();
+ expect(screen.getByText(/Go models need a Go or Go Plus plan/)).toBeInTheDocument();
+ await waitFor(() =>
+ expect(saveAiProvider).toHaveBeenCalledWith({
+ provider: "OPENCODE_GO",
+ baseUrl: "https://opencode.ai/inference/go",
+ model: "glm-5.3-flash",
+ }),
+ );
+ });
+
it("fills in Mistral's address and model, and names Mistral in the note (#206)", async () => {
render();
await userEvent.click(screen.getByRole("radio", { name: "Mistral" }));
diff --git a/src/lib/ai/config.test.ts b/src/lib/ai/config.test.ts
index e1f80c4b..c48b2528 100644
--- a/src/lib/ai/config.test.ts
+++ b/src/lib/ai/config.test.ts
@@ -50,6 +50,16 @@ describe("resolveAiConfig", () => {
});
});
+ it("knows OpenCode Go's base URL and a default model", () => {
+ expect(resolveAiConfig(nothingSaved, { DOSSIER_AI_PROVIDER: "opencode-go", DOSSIER_AI_KEY: "oc_sk-env" })).toMatchObject({
+ provider: "OPENCODE_GO",
+ baseUrl: "https://opencode.ai/inference/go",
+ model: "glm-5.3-flash",
+ apiKey: "oc_sk-env",
+ timeoutMs: 120_000,
+ });
+ });
+
it("finds LM Studio on the Docker host, as patient as a local model", () => {
expect(resolveAiConfig(nothingSaved, { DOSSIER_AI_PROVIDER: "lmstudio" })).toMatchObject({
provider: "LMSTUDIO",
@@ -84,6 +94,7 @@ describe("createProvider", () => {
const base = { baseUrl: "http://x", model: "m", apiKey: "k", apiKeyProblem: null, timeoutMs: 1000 };
expect(createProvider({ ...base, provider: "LOCAL" }).kind).toBe("LOCAL");
expect(createProvider({ ...base, provider: "OPENAI" }).kind).toBe("OPENAI");
+ expect(createProvider({ ...base, provider: "OPENCODE_GO" }).kind).toBe("OPENCODE_GO");
expect(createProvider({ ...base, provider: "ANTHROPIC" }).kind).toBe("ANTHROPIC");
expect(createProvider({ ...base, provider: "MISTRAL" }).kind).toBe("MISTRAL");
expect(createProvider({ ...base, provider: "LMSTUDIO" }).kind).toBe("LMSTUDIO");
@@ -108,5 +119,7 @@ describe("isAiUsable (#221)", () => {
expect(await usable({ provider: "ANTHROPIC" })).toBe(false);
expect(await usable({ provider: "ANTHROPIC", apiKeyEnc: encryptApiKey("sk-saved") })).toBe(true);
expect(await usable({ provider: "MISTRAL", apiKeyEnc: encryptApiKey("sk", "b".repeat(32)) })).toBe(false);
+ expect(await usable({ provider: "OPENCODE_GO" })).toBe(false);
+ expect(await usable({ provider: "OPENCODE_GO", apiKeyEnc: encryptApiKey("oc_sk-saved") })).toBe(true);
});
});
diff --git a/src/lib/ai/config.ts b/src/lib/ai/config.ts
index 41a9d411..f78e81fb 100644
--- a/src/lib/ai/config.ts
+++ b/src/lib/ai/config.ts
@@ -24,6 +24,7 @@ export const PROVIDER_DEFAULTS: Record, AiProvider> = {
local: AiProvider.LOCAL,
openai: AiProvider.OPENAI,
+ "opencode-go": AiProvider.OPENCODE_GO,
anthropic: AiProvider.ANTHROPIC,
mistral: AiProvider.MISTRAL,
lmstudio: AiProvider.LMSTUDIO,
@@ -126,6 +128,9 @@ export async function isAiUsable(): Promise {
if (!(await isAiConfigured())) return false;
const config = await loadAiConfig();
if (!config.model || config.apiKeyProblem) return false;
- const needsKey = config.provider === AiProvider.ANTHROPIC || config.provider === AiProvider.MISTRAL;
+ const needsKey =
+ config.provider === AiProvider.ANTHROPIC ||
+ config.provider === AiProvider.MISTRAL ||
+ config.provider === AiProvider.OPENCODE_GO;
return !needsKey || Boolean(config.apiKey);
}
diff --git a/src/lib/ai/index.ts b/src/lib/ai/index.ts
index faef5f2b..71e5506c 100644
--- a/src/lib/ai/index.ts
+++ b/src/lib/ai/index.ts
@@ -10,13 +10,14 @@ import { AnthropicProvider } from "./providers/anthropic";
import { LocalProvider } from "./providers/local";
import { LmStudioProvider } from "./providers/lmstudio";
import { MistralProvider } from "./providers/mistral";
+import { OpenCodeGoProvider } from "./providers/opencode-go";
import { OpenAICompatibleProvider } from "./providers/openai-compatible";
export { AiError } from "./errors";
export type { AIProvider, ChatMessage, CompletionRequest, JsonCompletionRequest } from "./provider";
/** A provider for `config`. A saved key that can't be read stops hosted providers with a clear message. */
-export function createProvider(config: AiConfig): AIProvider {
+export function createProvider(config: AiConfig, sessionId?: string): AIProvider {
const { provider, baseUrl, model, apiKey, apiKeyProblem, timeoutMs } = config;
if (apiKeyProblem && providerOption(provider).apiKey) throw AiError.config(apiKeyProblem);
switch (provider) {
@@ -24,6 +25,8 @@ export function createProvider(config: AiConfig): AIProvider {
return new LocalProvider(baseUrl, model, timeoutMs);
case AiProvider.OPENAI:
return new OpenAICompatibleProvider("OPENAI", baseUrl, apiKey, model, timeoutMs);
+ case AiProvider.OPENCODE_GO:
+ return new OpenCodeGoProvider(baseUrl, apiKey, model, timeoutMs, sessionId);
case AiProvider.ANTHROPIC:
return new AnthropicProvider(baseUrl, apiKey, model, timeoutMs);
case AiProvider.MISTRAL:
@@ -34,6 +37,6 @@ export function createProvider(config: AiConfig): AIProvider {
}
/** The provider configured in Settings, or by the DOSSIER_AI_* variables. */
-export async function getAiProvider(): Promise {
- return createProvider(await loadAiConfig());
+export async function getAiProvider(sessionId?: string): Promise {
+ return createProvider(await loadAiConfig(), sessionId);
}
diff --git a/src/lib/ai/provider-options.ts b/src/lib/ai/provider-options.ts
index 72982ffd..8407a299 100644
--- a/src/lib/ai/provider-options.ts
+++ b/src/lib/ai/provider-options.ts
@@ -60,6 +60,22 @@ export const PROVIDER_OPTIONS: readonly {
defaultModel: "",
apiKey: { label: "API key" },
},
+ {
+ value: AiProvider.OPENCODE_GO,
+ name: "OpenCode Go",
+ vendor: "OpenCode",
+ description: "Open coding models through an OpenCode Go subscription.",
+ tag: "Hosted · Go subscription",
+ local: false,
+ defaultUrl: "https://opencode.ai/inference/go",
+ urlPlaceholder: "https://opencode.ai/inference/go",
+ urlHint: "The Go base URL from OpenCode's docs. Dossier adds the OpenAI-compatible path for chat.",
+ defaultModel: "glm-5.3-flash",
+ apiKey: {
+ label: "OpenCode API key",
+ hint: "From the OpenCode Console. The same key works with OpenCode Zen, but Go models need a Go or Go Plus plan.",
+ },
+ },
{
value: AiProvider.ANTHROPIC,
name: "Anthropic",
diff --git a/src/lib/ai/providers.live.test.ts b/src/lib/ai/providers.live.test.ts
index 024cc059..48d03506 100644
--- a/src/lib/ai/providers.live.test.ts
+++ b/src/lib/ai/providers.live.test.ts
@@ -5,9 +5,10 @@ import { AnthropicProvider } from "./providers/anthropic";
import { LmStudioProvider } from "./providers/lmstudio";
import { LocalProvider } from "./providers/local";
import { MistralProvider } from "./providers/mistral";
+import { OpenCodeGoProvider } from "./providers/opencode-go";
import { OpenAICompatibleProvider } from "./providers/openai-compatible";
import type { AIProvider } from "./provider";
-import { liveLmStudio, liveMistral } from "./test-live";
+import { liveLmStudio, liveMistral, liveOpenCodeGo } from "./test-live";
// Checks against real models, skipped unless asked for:
//
@@ -15,9 +16,12 @@ import { liveLmStudio, liveMistral } from "./test-live";
// AI_LIVE=ollama docker/bin/yarn vitest run src/lib/ai/providers.live.test.ts
// AI_LIVE=anthropic ANTHROPIC_TEST_KEY=… docker/bin/yarn vitest run src/lib/ai/providers.live.test.ts
// AI_LIVE=mistral docker/bin/yarn vitest run src/lib/ai/providers.live.test.ts
+// AI_LIVE=opencode-go OPENCODE_TEST_KEY=… docker/bin/yarn vitest run src/lib/ai/providers.live.test.ts
//
// Mistral's key is DOSSIER_AI_KEY when that's set, or else the one saved in
-// Settings → AI provider with Mistral chosen.
+// Settings → AI provider with Mistral chosen. OpenCode Go's key is
+// OPENCODE_TEST_KEY (and OPENCODE_TEST_MODEL), or the one saved in Settings
+// with OpenCode Go chosen.
//
// AI_LIVE=lmstudio docker/bin/yarn vitest run src/lib/ai/providers.live.test.ts
//
@@ -111,6 +115,29 @@ describe.runIf(mistral)("live: Mistral", () => {
});
});
+const openCodeGo = live.has("opencode-go") ? await liveOpenCodeGo() : null;
+
+describe.runIf(openCodeGo)("live: OpenCode Go", () => {
+ const { key, model } = openCodeGo ?? { key: "", model: "" };
+ const make = (apiKey = key) => new OpenCodeGoProvider("https://opencode.ai/inference/go", apiKey, model, 120_000);
+ checks("chat completions", () => make(), model);
+
+ it("answers a reading task in plain JSON", async () => {
+ const result = await make().complete({
+ system: "Extract the job details from the posting.",
+ messages: [{ role: "user", content: posting }],
+ schema: Posting,
+ schemaName: "posting",
+ effort: "low",
+ });
+ expect(result).toMatchObject({ company: "Ledgerly", city: "Amsterdam" });
+ });
+
+ it("refuses a wrong key in plain words", async () => {
+ await expect(make("oc_sk-wrong").test()).rejects.toMatchObject({ kind: "auth" });
+ });
+});
+
const lmStudio = live.has("lmstudio") ? await liveLmStudio() : null;
describe.runIf(lmStudio)("live: LM Studio", () => {
diff --git a/src/lib/ai/providers.test.ts b/src/lib/ai/providers.test.ts
index 462ebf91..402d1f67 100644
--- a/src/lib/ai/providers.test.ts
+++ b/src/lib/ai/providers.test.ts
@@ -8,6 +8,7 @@ import { LocalProvider } from "./providers/local";
import { MISTRAL_FALLBACK_MODELS, MistralProvider } from "./providers/mistral";
import { MockProvider } from "./providers/mock";
import { OllamaProvider } from "./providers/ollama";
+import { OpenCodeGoProvider, openCodeGoRoot, openCodeGoSession } from "./providers/opencode-go";
import { OpenAICompatibleProvider } from "./providers/openai-compatible";
import { hang, json, refused, stubFetch } from "./test-http";
@@ -149,6 +150,136 @@ describe("OpenAICompatibleProvider", () => {
});
});
+describe("OpenCodeGoProvider", () => {
+ it("chats through Go's OpenAI-compatible endpoint, identifying Dossier and the conversation", async () => {
+ const requests = stubFetch(() => openaiChat("Hello!"));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "glm-5.3-flash", 60_000, "application-1");
+ expect(await provider.complete(ask)).toBe("Hello!");
+ expect(requests[0].url.href).toBe("https://opencode.ai/inference/go/openai/v1/chat/completions");
+ expect(requests[0].headers.get("authorization")).toBe("Bearer oc_sk-test");
+ expect(requests[0].headers.get("user-agent")).toMatch(/^Dossier\//);
+ expect(requests[0].headers.get("x-opencode-session")).toBe("ses_application-1");
+
+ await provider.complete(ask);
+ expect(requests[1].headers.get("x-opencode-session")).toBe("ses_application-1");
+ });
+
+ it("accepts the documented base URL or the OpenAI-compatible one", async () => {
+ expect(openCodeGoRoot("https://opencode.ai/inference/go")).toBe("https://opencode.ai/inference/go");
+ expect(openCodeGoRoot("https://opencode.ai/inference/go/")).toBe("https://opencode.ai/inference/go");
+ expect(openCodeGoRoot("https://opencode.ai/inference/go/openai/v1/")).toBe("https://opencode.ai/inference/go");
+ expect(openCodeGoSession("application-1")).toBe("ses_application-1");
+ expect(openCodeGoSession("ses_application-1")).toBe("ses_application-1");
+ expect(openCodeGoSession()).toMatch(/^ses_/);
+
+ const requests = stubFetch(() => openaiChat("Hello!"));
+ await new OpenCodeGoProvider("https://opencode.ai/inference/go/openai/v1/", "oc_sk", "glm-5.3-flash", 60_000).complete(ask);
+ expect(requests[0].url.href).toBe("https://opencode.ai/inference/go/openai/v1/chat/completions");
+ });
+
+ it("puts the schema in the instructions, since some Go models refuse response_format", async () => {
+ const requests = stubFetch(() => openaiChat('{"name":"Jane","years":9}'));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk", "glm-5.3-flash", 60_000);
+ expect(await provider.complete({ ...ask, schema: Person, schemaName: "person" })).toEqual({ name: "Jane", years: 9 });
+ const body = requests[0].body as { response_format?: unknown; messages: { content: string }[] };
+ expect(body.response_format).toBeUndefined();
+ expect(body.messages[0].content).toMatch(/Reply with only a JSON object[\s\S]*"years"/);
+ });
+
+ it("lists the Go catalog from /v1/models without sending the key", async () => {
+ const requests = stubFetch(({ url }) => {
+ expect(url.href).toBe("https://opencode.ai/inference/go/v1/models");
+ return json({ object: "list", data: [{ id: "glm-5.3-flash" }, { id: "deepseek-v4.1-flash" }] });
+ });
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", null, 60_000);
+ expect(await provider.listModels()).toEqual(["deepseek-v4.1-flash", "glm-5.3-flash"]);
+ expect(requests[0].headers.get("authorization")).toBeNull();
+ expect(requests[0].headers.get("user-agent")).toMatch(/^Dossier\//);
+ expect(requests[0].headers.get("x-opencode-session")).toMatch(/^ses_/);
+ });
+
+ it("keeps only IDs from a model list that has odd entries", async () => {
+ stubFetch(() => json({ object: "list", data: [{ id: "glm-5.3-flash" }, { id: 4 }, {}] }));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", null, 60_000);
+ expect(await provider.listModels()).toEqual(["glm-5.3-flash"]);
+ });
+
+ it("reports a model list the server won't give", async () => {
+ stubFetch(() => json({ error: { message: "temporarily unavailable" } }, { status: 500 }));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", null, 60_000);
+ expect(await failure(provider.listModels())).toMatchObject({ kind: "provider" });
+ });
+
+ it("treats an unreadable model list as empty", async () => {
+ stubFetch(() => new Response("not json", { status: 200, headers: { "content-type": "text/plain" } }));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", null, 60_000);
+ expect(await provider.listModels()).toEqual([]);
+ });
+
+ it("asks for a key before chatting", async () => {
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", null, "glm-5.3-flash", 60_000);
+ expect(await failure(provider.complete(ask))).toMatchObject({
+ kind: "config",
+ message: "Add your OpenCode API key in Settings → AI provider first.",
+ });
+ });
+
+ it("explains a key without a Go plan", async () => {
+ stubFetch(() => json({ error: { message: "An active OpenCode Go subscription is required to use Go models." } }, { status: 403 }));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-wrong-plan", "glm-5.3-flash", 60_000);
+ const error = await failure(provider.complete(ask));
+ expect(error).toMatchObject({ kind: "config", message: expect.stringContaining("Go or Go Plus") });
+ expect(error.detail ?? "").not.toContain("oc_sk");
+ });
+
+ it("explains a workspace region restriction", async () => {
+ stubFetch(() =>
+ json(
+ { error: { message: "This Go model requires Global regions. Select Global in your workspace's Privacy settings to use it." } },
+ { status: 403 },
+ ),
+ );
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "deepseek-v4.1-flash", 60_000);
+ const error = await failure(provider.complete(ask));
+ expect(error).toMatchObject({ kind: "config", message: expect.stringContaining("Global regions") });
+ });
+
+ it("explains a model that needs training opt-in, and a free one", async () => {
+ stubFetch(() => json({ error: { message: "This contributor model trains on request data." } }, { status: 403 }));
+ const contributor = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "muse-spark-1.3-contributor", 60_000);
+ expect(await failure(contributor.complete(ask))).toMatchObject({
+ kind: "config",
+ message: expect.stringContaining("train on request data"),
+ });
+
+ stubFetch(() => json({ error: { message: "Free models are only available from within OpenCode." } }, { status: 403 }));
+ const free = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "beam-free", 60_000);
+ expect(await failure(free.complete(ask))).toMatchObject({
+ kind: "config",
+ message: "That model is only available inside OpenCode. Pick a paid model included in your Go plan.",
+ });
+ });
+
+ it("keeps a 403 it can't explain as a key problem", async () => {
+ stubFetch(() => json({ error: { message: "Forbidden" } }, { status: 403 }));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "glm-5.3-flash", 60_000);
+ expect(await failure(provider.complete(ask))).toMatchObject({ kind: "auth" });
+ });
+
+ it("lets the base provider map a failure it doesn't explain", async () => {
+ stubFetch(() => refused());
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "glm-5.3-flash", 60_000);
+ expect(await failure(provider.complete(ask))).toMatchObject({ kind: "unreachable" });
+ });
+
+ it("mentions the Go plan's limits when requests are throttled", async () => {
+ stubFetch(() => json({ error: { message: "Provider rate limit exceeded" } }, { status: 429 }));
+ const provider = new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk-test", "glm-5.3-flash", 60_000);
+ const error = await failure(provider.complete(ask));
+ expect(error).toMatchObject({ kind: "rate-limited", message: expect.stringContaining("5-hour") });
+ });
+});
+
describe("AnthropicProvider", () => {
it("sends no temperature, since current models refuse one, and leaves room for thinking", async () => {
const requests = stubFetch(() => anthropicMessage("Hello!"));
diff --git a/src/lib/ai/providers/openai-compatible.ts b/src/lib/ai/providers/openai-compatible.ts
index ffafb46e..baf19e4e 100644
--- a/src/lib/ai/providers/openai-compatible.ts
+++ b/src/lib/ai/providers/openai-compatible.ts
@@ -37,11 +37,12 @@ export class OpenAICompatibleProvider extends BaseProvider {
protected readonly client: OpenAI;
constructor(
- readonly kind: "OPENAI" | "LOCAL" | "MISTRAL" | "LMSTUDIO",
+ readonly kind: "OPENAI" | "LOCAL" | "MISTRAL" | "LMSTUDIO" | "OPENCODE_GO",
private readonly baseURL: string,
apiKey: string | null,
model: string | null,
timeoutMs: number,
+ defaultHeaders?: Record,
) {
super(model, timeoutMs);
// Local servers often need no key, but the SDK insists on one.
@@ -50,14 +51,25 @@ export class OpenAICompatibleProvider extends BaseProvider {
apiKey: apiKey || "not-needed",
timeout: timeoutMs,
maxRetries: 1,
+ ...(defaultHeaders ? { defaultHeaders } : {}),
// With a key attached, no redirects to other hosts (E11-5).
...(apiKey ? { fetch: keyGuardedFetch() } : {}),
});
}
+ /** The JSON mode to start with; a server that can't take a schema can override this. */
+ protected defaultJsonMode(): JsonMode {
+ return "schema";
+ }
+
protected async chat(call: ChatCall): Promise {
- const key = `${this.baseURL} ${call.model}`;
- let known: Quirks = quirks.get(key) ?? { noTemperature: false, maxCompletionTokens: false, noEffort: false, json: "schema" };
+ const key = `${this.kind} ${this.baseURL} ${call.model}`;
+ let known: Quirks = quirks.get(key) ?? {
+ noTemperature: false,
+ maxCompletionTokens: false,
+ noEffort: false,
+ json: this.defaultJsonMode(),
+ };
// A 400 or 422 naming a parameter this model doesn't take: adapt and
// remember, once per parameter.
for (;;) {
diff --git a/src/lib/ai/providers/opencode-go.ts b/src/lib/ai/providers/opencode-go.ts
new file mode 100644
index 00000000..521bc37b
--- /dev/null
+++ b/src/lib/ai/providers/opencode-go.ts
@@ -0,0 +1,127 @@
+import "server-only";
+
+import { randomUUID } from "node:crypto";
+
+import OpenAI from "openai";
+
+import { appVersion } from "@/lib/version";
+
+import { AiError } from "../errors";
+import type { ChatAnswer, ChatCall } from "../provider";
+import { OpenAICompatibleProvider } from "./openai-compatible";
+
+const USER_AGENT = `Dossier/${appVersion} (+https://github.com/AienTech/dossier)`;
+
+/**
+ * OpenCode Go, through its OpenAI-compatible endpoint. Go asks clients to
+ * identify themselves and send a stable session header, and its model list
+ * lives outside the OpenAI-compatible path.
+ */
+export class OpenCodeGoProvider extends OpenAICompatibleProvider {
+ private readonly apiKey: string | null;
+ private readonly root: string;
+ private readonly session: string;
+
+ constructor(address: string, apiKey: string | null, model: string | null, timeoutMs: number, sessionId?: string) {
+ const root = openCodeGoRoot(address);
+ const session = openCodeGoSession(sessionId);
+ super("OPENCODE_GO", `${root}/openai/v1`, apiKey, model, timeoutMs, {
+ "User-Agent": USER_AGENT,
+ "x-opencode-session": session,
+ });
+ this.apiKey = apiKey;
+ this.root = root;
+ this.session = session;
+ }
+
+ /** Some Go models refuse `response_format`; the schema goes in the instructions instead. */
+ protected override defaultJsonMode(): "prompt" {
+ return "prompt";
+ }
+
+ protected override async chat(call: ChatCall): Promise {
+ if (!this.apiKey) throw AiError.config("Add your OpenCode API key in Settings → AI provider first.");
+ try {
+ return await super.chat(call);
+ } catch (error) {
+ const mapped = goError(error);
+ if (mapped) throw mapped;
+ throw error;
+ }
+ }
+
+ /** The Go catalog is public, so the model list works before a key is saved. */
+ protected override async fetchModels(signal: AbortSignal): Promise {
+ const response = await fetch(`${this.root}/v1/models`, {
+ headers: {
+ "User-Agent": USER_AGENT,
+ "x-opencode-session": this.session,
+ Accept: "application/json",
+ },
+ signal,
+ });
+ if (!response.ok) {
+ throw Object.assign(new Error(`OpenCode Go's model list failed with HTTP ${response.status}.`), {
+ name: "OpenCodeGoError",
+ status: response.status,
+ });
+ }
+ const body = (await response.json().catch(() => null)) as { data?: unknown } | null;
+ const entries = Array.isArray(body?.data) ? body.data : [];
+ return entries
+ .flatMap((entry) => {
+ const id = (entry as { id?: unknown })?.id;
+ return typeof id === "string" ? [id] : [];
+ })
+ .sort();
+ }
+}
+
+/** The Go root from either the documented base URL or the OpenAI-compatible one. */
+export function openCodeGoRoot(address: string): string {
+ return address
+ .trim()
+ .replace(/\/+$/, "")
+ .replace(/\/(?:openai\/v1|openai|v1)$/, "");
+}
+
+/** A session ID in the form OpenCode expects: the given conversation, or a random one for a one-off call. */
+export function openCodeGoSession(sessionId?: string): string {
+ const id = sessionId?.trim() || randomUUID();
+ return id.startsWith("ses_") ? id : `ses_${id}`;
+}
+
+/** Go's 403s say whether it's the plan, the workspace's privacy settings or a free model. */
+function goError(error: unknown): AiError | null {
+ if (error instanceof AiError) return error;
+ if (error instanceof OpenAI.PermissionDeniedError) {
+ const message = error.message;
+ if (/active OpenCode Go subscription|Go models|Go subscription|Account\.Disabled/i.test(message)) {
+ return AiError.config(
+ "This OpenCode API key isn't on a Go or Go Plus plan. Subscribe in the OpenCode Console, or pick another provider in Settings → AI provider.",
+ );
+ }
+ if (/regions?|privacy/i.test(message)) {
+ return AiError.config(
+ "OpenCode blocks this model for your workspace's region. In the OpenCode Console, allow Global regions under Settings → Privacy, or pick another model.",
+ );
+ }
+ if (/train|contributor/i.test(message)) {
+ return AiError.config(
+ "This model is only available if your OpenCode workspace allows paid endpoints that train on request data. Change that in the OpenCode Console under Settings → Privacy, or pick another model.",
+ );
+ }
+ if (/free[- ]tier|free models?/i.test(message)) {
+ return AiError.config("That model is only available inside OpenCode. Pick a paid model included in your Go plan.");
+ }
+ return AiError.of("auth", "PermissionDeniedError 403");
+ }
+ if (error instanceof OpenAI.RateLimitError) {
+ return new AiError(
+ "rate-limited",
+ "OpenCode is limiting requests right now. If you've reached your Go plan's 5-hour, weekly or monthly limit, check the OpenCode Console; otherwise wait a minute and try again.",
+ "RateLimitError 429",
+ );
+ }
+ return null;
+}
diff --git a/src/lib/ai/structured-output.test.ts b/src/lib/ai/structured-output.test.ts
index 79893867..5368f40b 100644
--- a/src/lib/ai/structured-output.test.ts
+++ b/src/lib/ai/structured-output.test.ts
@@ -6,6 +6,7 @@ import { extractJson, validateJson, type AIProvider } from "./provider";
import { AnthropicProvider } from "./providers/anthropic";
import { MockProvider } from "./providers/mock";
import { OllamaProvider } from "./providers/ollama";
+import { OpenCodeGoProvider } from "./providers/opencode-go";
import { OpenAICompatibleProvider } from "./providers/openai-compatible";
import { json, stubFetch, type FakeRequest } from "./test-http";
@@ -64,6 +65,13 @@ const harnesses: Harness[] = [
json({ id: "c", object: "chat.completion", created: 0, model: "gpt-x", choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: cutOff ? "length" : "stop" }] }),
conversation: (request) => (request.body as { messages: unknown[] }).messages,
},
+ {
+ name: "OpenCode Go",
+ make: () => new OpenCodeGoProvider("https://opencode.ai/inference/go", "oc_sk", "glm-5.3-flash", 60_000),
+ respond: (content, cutOff) =>
+ json({ id: "c", object: "chat.completion", created: 0, model: "glm-5.3-flash", choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: cutOff ? "length" : "stop" }] }),
+ conversation: (request) => (request.body as { messages: unknown[] }).messages,
+ },
{
name: "Anthropic",
make: () => new AnthropicProvider("https://api.anthropic.com", "sk-ant", "claude-sonnet-5", 60_000),
diff --git a/src/lib/ai/test-live.ts b/src/lib/ai/test-live.ts
index f6548bde..76679ce9 100644
--- a/src/lib/ai/test-live.ts
+++ b/src/lib/ai/test-live.ts
@@ -24,6 +24,16 @@ export async function liveMistral(): Promise<{ key: string; model: string } | nu
return saved.provider === AiProvider.MISTRAL && saved.apiKey ? { key: saved.apiKey, model: saved.model ?? fallbackModel } : null;
}
+/** OpenCode Go's key and model: OPENCODE_TEST_KEY (and OPENCODE_TEST_MODEL), or what's saved in Settings with OpenCode Go chosen. */
+export async function liveOpenCodeGo(): Promise<{ key: string; model: string } | null> {
+ const fallbackModel = "glm-5.3-flash";
+ if (process.env.OPENCODE_TEST_KEY) {
+ return { key: process.env.OPENCODE_TEST_KEY, model: process.env.OPENCODE_TEST_MODEL ?? fallbackModel };
+ }
+ const saved = await savedAiConfig();
+ return saved.provider === AiProvider.OPENCODE_GO && saved.apiKey ? { key: saved.apiKey, model: saved.model ?? fallbackModel } : null;
+}
+
/** LM Studio's address, token and model: DOSSIER_AI_MODEL (and DOSSIER_AI_URL, DOSSIER_AI_KEY), or what's saved in Settings with LM Studio chosen. */
export async function liveLmStudio(): Promise<{ url: string; key: string | null; model: string } | null> {
const { DOSSIER_AI_URL, DOSSIER_AI_KEY, DOSSIER_AI_MODEL } = process.env;
diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts
index 81556854..7c38edce 100644
--- a/src/lib/env.test.ts
+++ b/src/lib/env.test.ts
@@ -125,7 +125,7 @@ describe("parseEnv", () => {
}),
).toEqual([
'DOSSIER_BACKUP_RETENTION_DAYS must be a whole number of days (got "two weeks").',
- 'DOSSIER_AI_PROVIDER must be one of local, openai, anthropic, mistral, lmstudio (got "gemini").',
+ 'DOSSIER_AI_PROVIDER must be one of local, openai, anthropic, mistral, lmstudio, opencode-go (got "gemini").',
"DOSSIER_AI_URL must be an http:// or https:// URL.",
]);
expect(problems({ ...valid, DOSSIER_BACKUP_RETENTION_DAYS: "0" })).toEqual([
diff --git a/src/lib/env.ts b/src/lib/env.ts
index 7bd9a88e..bda3f53d 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -10,7 +10,7 @@ const SECRET_MIN_LENGTH = 32;
const GENERATE_SECRET = "for example the output of `openssl rand -base64 32`";
const HASH_PASSWORD = "Make one with `docker run --rm -it ghcr.io/aientech/dossier hash-password`";
-export const AI_PROVIDERS = ["local", "openai", "anthropic", "mistral", "lmstudio"] as const;
+export const AI_PROVIDERS = ["local", "openai", "anthropic", "mistral", "lmstudio", "opencode-go"] as const;
export const JOB_SOURCE_NAMES = ["jsearch", "adzuna", "arbeitsagentur", "off"] as const;
// An empty value (`DOSSIER_AI_KEY=` in .env) counts as unset.
diff --git a/src/lib/jobs/refine.ts b/src/lib/jobs/refine.ts
index 10986acd..6ac26291 100644
--- a/src/lib/jobs/refine.ts
+++ b/src/lib/jobs/refine.ts
@@ -70,7 +70,13 @@ export const refineJob = defineJob({
);
const targetUnit = target && unitsOf(target.document).find((unit) => unit.id === target.unitId);
- const [provider, options, profile, facts] = await Promise.all([getAiProvider(), loadTaskOptions(), loadProfile(), loadFactTexts(applicationId)]);
+ // One OpenCode session per application keeps a Refine conversation together.
+ const [provider, options, profile, facts] = await Promise.all([
+ getAiProvider(applicationId),
+ loadTaskOptions(),
+ loadProfile(),
+ loadFactTexts(applicationId),
+ ]);
const { data, promptVersion } = await refineChat(
provider,
{