Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AiProvider" ADD VALUE 'OPENCODE_GO';
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ <h1 class="display">Your job search, <em>on your own server.</em></h1>
<li>Ollama</li>
<li>LM Studio</li>
<li>OpenAI-compatible APIs</li>
<li>OpenCode Go</li>
<li>Anthropic</li>
<li>Mistral</li>
</ul>
Expand Down
17 changes: 17 additions & 0 deletions src/app/(app)/settings/ai-provider-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe("AiProviderSection", () => {
"Local model",
"LM Studio",
"OpenAI-compatible",
"OpenCode Go",
"Anthropic",
"Mistral",
]);
Expand All @@ -55,6 +56,22 @@ describe("AiProviderSection", () => {
);
});

it("fills in OpenCode Go's address and model, with its key and privacy note", async () => {
render(<AiProviderSection initial={local} />);
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(<AiProviderSection initial={local} />);
await userEvent.click(screen.getByRole("radio", { name: "Mistral" }));
Expand Down
13 changes: 13 additions & 0 deletions src/lib/ai/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand All @@ -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);
});
});
7 changes: 6 additions & 1 deletion src/lib/ai/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const PROVIDER_DEFAULTS: Record<AiProvider, { baseUrl: string; model: str
// Generous: a local model on a laptop can take minutes for a cover letter.
[AiProvider.LOCAL]: { baseUrl: "http://localhost:11434", model: null, timeoutSeconds: 300 },
[AiProvider.OPENAI]: { baseUrl: "https://api.openai.com/v1", model: null, timeoutSeconds: 120 },
[AiProvider.OPENCODE_GO]: { baseUrl: "https://opencode.ai/inference/go", model: "glm-5.3-flash", timeoutSeconds: 120 },
[AiProvider.ANTHROPIC]: { baseUrl: "https://api.anthropic.com", model: "claude-sonnet-5", timeoutSeconds: 120 },
[AiProvider.MISTRAL]: { baseUrl: "https://api.mistral.ai/v1", model: "mistral-medium-latest", timeoutSeconds: 120 },
// As generous as a local model, and loading one can take a while too.
Expand All @@ -33,6 +34,7 @@ export const PROVIDER_DEFAULTS: Record<AiProvider, { baseUrl: string; model: str
const FROM_ENV: Record<NonNullable<Env["DOSSIER_AI_PROVIDER"]>, AiProvider> = {
local: AiProvider.LOCAL,
openai: AiProvider.OPENAI,
"opencode-go": AiProvider.OPENCODE_GO,
anthropic: AiProvider.ANTHROPIC,
mistral: AiProvider.MISTRAL,
lmstudio: AiProvider.LMSTUDIO,
Expand Down Expand Up @@ -126,6 +128,9 @@ export async function isAiUsable(): Promise<boolean> {
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);
}
9 changes: 6 additions & 3 deletions src/lib/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,23 @@ 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) {
case AiProvider.LOCAL:
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:
Expand All @@ -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<AIProvider> {
return createProvider(await loadAiConfig());
export async function getAiProvider(sessionId?: string): Promise<AIProvider> {
return createProvider(await loadAiConfig(), sessionId);
}
16 changes: 16 additions & 0 deletions src/lib/ai/provider-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 29 additions & 2 deletions src/lib/ai/providers.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ 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:
//
// docker compose up -d ollama # with qwen2.5:3b pulled (see compose.yml)
// 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
//
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading