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, {