Skip to content

Commit 03c072b

Browse files
committed
feat(selfhost): Tier 1 — multi-provider BYOK + fallback chain + native Anthropic + CLI image (#979)
- Native Anthropic Messages API adapter (BYOK; splits system, joins text blocks) — distinct from the claude-code subscription path. - AI_PROVIDER now accepts a COMMA-LIST → a fallback chain (createChainAi): tries each provider in order, logs failures, returns the first success; all-fail throws so the review degrades. e.g. 'anthropic,ollama'. - Per-provider credentials: ANTHROPIC_API_KEY / OPENAI_API_KEY / AI_API_KEY; a provider with no key is dropped from the chain. openai → api.openai.com default base URL. - Dockerfile --build-arg INSTALL_AI_CLIS=true bakes @anthropic-ai/claude-code + @openai/codex so the subscription providers work in-image (no credentials baked; operator mints the token at run time). - Docs + .env.example updated. +3 tests (Anthropic shape, chain fallback + all-fail). 32 self-host tests green.
1 parent 4bcbc3e commit 03c072b

5 files changed

Lines changed: 155 additions & 14 deletions

File tree

.env.example

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,13 @@ GITTENSORY_REVIEW_DRAFT=false
112112

113113
# --- AI review backend (optional; without it reviews run deterministically) ---
114114
# AI_SUMMARIES_ENABLED=true
115-
# AI_PROVIDER=ollama # ollama | openai-compatible | claude-code | codex (see #979)
116-
# AI_BASE_URL=http://ollama:11434/v1 # an OpenAI-compatible endpoint (the Ollama default)
117-
# AI_API_KEY= # if your endpoint requires a key
115+
# AI_PROVIDER=ollama # ollama | openai-compatible | openai | anthropic | claude-code |
116+
# # codex. A COMMA-LIST is a fallback chain, e.g. "anthropic,ollama"
117+
# # (tries each in order until one succeeds). (see #979)
118+
# AI_BASE_URL=http://ollama:11434/v1 # OpenAI-compatible endpoint (Ollama default; or your provider's)
119+
# AI_API_KEY= # generic key for the openai-compatible endpoint
120+
# ANTHROPIC_API_KEY= # for AI_PROVIDER=anthropic (native Messages API, BYOK)
121+
# OPENAI_API_KEY= # for AI_PROVIDER=openai
118122
# AI_MODEL=llama3.1 # the model for your provider (e.g. llama3.1 for Ollama, sonnet
119123
# # for claude-code, gpt-5 for codex). REQUIRED for non-Ollama:
120124
# # without it the adapter falls back to a provider default, never

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ COPY --from=build /app/node_modules ./node_modules
2525
COPY --from=build /app/dist ./dist
2626
COPY --from=build /app/migrations ./migrations
2727
COPY --from=build /app/scripts/register-selfhost.mjs ./scripts/register-selfhost.mjs
28+
# Optional: bake the Claude Code / Codex CLIs so the `claude-code` / `codex` subscription providers (#979)
29+
# work in-image. Build with `--build-arg INSTALL_AI_CLIS=true`. No credentials are baked — operators mint
30+
# CLAUDE_CODE_OAUTH_TOKEN (`claude setup-token`) / codex auth at run time and pass it via the env.
31+
ARG INSTALL_AI_CLIS=false
32+
RUN if [ "$INSTALL_AI_CLIS" = "true" ]; then npm install -g @anthropic-ai/claude-code @openai/codex; fi
2833
# Data dir (the SQLite file) — owned by the unprivileged node user; mount a volume here to persist.
2934
RUN mkdir -p /data && chown -R node:node /data /app
3035
USER node

docs/self-hosting.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,19 @@ and only the AI **summary** degrades to "unavailable". To enable AI, set `AI_PRO
7676

7777
| `AI_PROVIDER` | Backend | Extra config |
7878
| --- | --- | --- |
79-
| `ollama` / `openai-compatible` / `openai` | any OpenAI-compatible `/chat/completions` endpoint | `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` |
79+
| `ollama` / `openai-compatible` / `openai` | any OpenAI-compatible `/chat/completions` endpoint (Ollama, OpenAI, Groq, Together, OpenRouter, vLLM, Gemini's OpenAI-compat endpoint, …) | `AI_BASE_URL`, `AI_API_KEY` (or `OPENAI_API_KEY`), `AI_MODEL` |
80+
| `anthropic` | **native Anthropic Messages API** (BYOK — bills your API key) | `ANTHROPIC_API_KEY`, `AI_MODEL` (e.g. `claude-sonnet-4-6`) |
8081
| `claude-code` | your **Claude** subscription via the `claude` CLI (read-only, headless) | `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), `AI_MODEL` (e.g. `sonnet`) |
8182
| `codex` | your **Codex** subscription via the `codex` CLI | local `codex` auth, `AI_MODEL` (e.g. `gpt-5`) |
8283

84+
**Fallback chain.** `AI_PROVIDER` accepts a comma-separated list and tries each in order until one succeeds —
85+
e.g. `AI_PROVIDER=anthropic,ollama` uses the Anthropic API first and falls back to a local Ollama model if it
86+
errors. If every provider fails, the AI summary degrades to "unavailable" and the review still runs.
87+
88+
**Subscription CLIs in the image.** The `claude-code` / `codex` providers need their CLI present. Build the
89+
image with `--build-arg INSTALL_AI_CLIS=true` (or `docker compose build --build-arg INSTALL_AI_CLIS=true`) to
90+
bake them in, then provide `CLAUDE_CODE_OAUTH_TOKEN` / codex auth at run time. No credentials are baked in.
91+
8392
> **Set `AI_MODEL`.** The core would otherwise hand the adapter a Cloudflare Workers-AI model id
8493
> (`@cf/meta/...`) that Ollama / `claude` / `codex` can't use. The adapter ignores that id in favour of
8594
> `AI_MODEL` (falling back to a provider default), so always set `AI_MODEL` to a real model for your provider.

src/selfhost/ai.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,37 @@ export function createOpenAiCompatibleAi(opts: { baseUrl: string; apiKey?: strin
5252
};
5353
}
5454

55+
/** Native Anthropic Messages API (BYOK — bills your Anthropic API key; distinct from the claude-code
56+
* subscription path). The system message becomes the top-level `system` param; the rest map to user/assistant. */
57+
export function createAnthropicAi(opts: { apiKey: string; model?: string | undefined; baseUrl?: string | undefined }): SelfHostAi {
58+
const base = (opts.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
59+
return {
60+
async run(model, options) {
61+
const msgs = toMessages(options);
62+
const system =
63+
msgs
64+
.filter((m) => m.role === "system")
65+
.map((m) => m.content)
66+
.join("\n\n") || undefined;
67+
const messages = msgs.filter((m) => m.role !== "system").map((m) => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content }));
68+
const res = await fetch(`${base}/v1/messages`, {
69+
method: "POST",
70+
headers: { "content-type": "application/json", "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" },
71+
body: JSON.stringify({ model: resolveModel(opts.model, model, "claude-sonnet-4-6"), max_tokens: options.max_tokens ?? 1024, ...(system ? { system } : {}), messages }),
72+
signal: AbortSignal.timeout(120_000),
73+
});
74+
if (!res.ok) throw new Error(`anthropic_http_${res.status}`);
75+
const data = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
76+
return {
77+
response: (data.content ?? [])
78+
.filter((c) => c.type === "text")
79+
.map((c) => c.text ?? "")
80+
.join(""),
81+
};
82+
},
83+
};
84+
}
85+
5586
// ── Subscription CLI providers (#979) — locally-authenticated `claude` / `codex` as a subprocess ──────────
5687
// SECURITY: the child env DELETES the billable API keys so a misconfigured CLI cannot silently bill the
5788
// metered API instead of using the subscription OAuth token. The CLI runs read-only / no extra tools. Any
@@ -169,14 +200,63 @@ export function createCodexAi(parentEnv: Record<string, string | undefined>, spa
169200
};
170201
}
171202

172-
/** Pick the self-host AI provider from env (AI_PROVIDER). Returns undefined when unconfigured. */
173-
export function createSelfHostAi(env: Record<string, string | undefined>): SelfHostAi | undefined {
174-
const provider = (env.AI_PROVIDER ?? "").trim().toLowerCase();
175-
if (!provider) return undefined;
176-
if (provider === "ollama" || provider === "openai-compatible" || provider === "openai") {
177-
return createOpenAiCompatibleAi({ baseUrl: env.AI_BASE_URL ?? "http://localhost:11434/v1", apiKey: env.AI_API_KEY, model: configuredModel(env) });
203+
/** Try each provider in order until one returns; if all throw, rethrow the last error so the caller degrades
204+
* (AI summary → "unavailable"; the review still runs deterministically). The fallback chain is what makes a
205+
* BYOK setup robust — e.g. AI_PROVIDER="anthropic,ollama" uses the API first and a local model if it's down. */
206+
export function createChainAi(providers: Array<{ name: string; ai: SelfHostAi }>): SelfHostAi {
207+
return {
208+
async run(model, options) {
209+
let lastError: unknown = new Error("no_ai_providers");
210+
for (const p of providers) {
211+
try {
212+
return await p.ai.run(model, options);
213+
} catch (error) {
214+
lastError = error;
215+
console.error(JSON.stringify({ level: "warn", event: "selfhost_ai_provider_failed", provider: p.name, error: error instanceof Error ? error.message : "unknown" }));
216+
}
217+
}
218+
throw lastError instanceof Error ? lastError : new Error("all_ai_providers_failed");
219+
},
220+
};
221+
}
222+
223+
/** Build one provider adapter by name (BYO credentials read from provider-specific env, then the generic
224+
* AI_API_KEY). Returns undefined when its required credential is missing. */
225+
export function buildProvider(name: string, env: Record<string, string | undefined>): SelfHostAi | undefined {
226+
switch (name) {
227+
case "ollama":
228+
case "openai-compatible":
229+
case "openai":
230+
return createOpenAiCompatibleAi({
231+
baseUrl: env.AI_BASE_URL ?? (name === "openai" ? "https://api.openai.com/v1" : "http://localhost:11434/v1"),
232+
apiKey: env.AI_API_KEY ?? env.OPENAI_API_KEY,
233+
model: configuredModel(env),
234+
});
235+
case "anthropic": {
236+
const apiKey = env.ANTHROPIC_API_KEY ?? env.AI_API_KEY;
237+
return apiKey ? createAnthropicAi({ apiKey, model: configuredModel(env), baseUrl: env.AI_BASE_URL }) : undefined;
238+
}
239+
case "claude-code":
240+
return createClaudeCodeAi(env);
241+
case "codex":
242+
return createCodexAi(env);
243+
default:
244+
return undefined;
178245
}
179-
if (provider === "claude-code") return createClaudeCodeAi(env);
180-
if (provider === "codex") return createCodexAi(env);
181-
return undefined;
246+
}
247+
248+
/** Select the self-host AI provider(s) from AI_PROVIDER. A comma-separated list builds a fallback chain
249+
* (first to succeed wins). Returns undefined when unconfigured or no provider has its credential. */
250+
export function createSelfHostAi(env: Record<string, string | undefined>): SelfHostAi | undefined {
251+
const raw = (env.AI_PROVIDER ?? "").trim().toLowerCase();
252+
if (!raw) return undefined;
253+
const providers = raw
254+
.split(",")
255+
.map((s) => s.trim())
256+
.filter(Boolean)
257+
.map((name) => ({ name, ai: buildProvider(name, env) }))
258+
.filter((p): p is { name: string; ai: SelfHostAi } => Boolean(p.ai));
259+
if (providers.length === 0) return undefined;
260+
if (providers.length === 1) return providers[0]?.ai;
261+
return createChainAi(providers);
182262
}

test/unit/selfhost-ai.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
5-
import { claudeErrorStatus, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, resolveModel } from "../../src/selfhost/ai";
5+
import { claudeErrorStatus, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, resolveModel } from "../../src/selfhost/ai";
66

77
describe("resolveModel (#979 — never leak the Workers-AI default to a self-host backend)", () => {
88
const WORKERS_DEFAULT = "@cf/meta/llama-3.1-8b-instruct-fp8-fast";
@@ -53,6 +53,49 @@ describe("createSelfHostAi — provider selection", () => {
5353
expect(typeof createSelfHostAi({ AI_PROVIDER: "codex" })?.run).toBe("function");
5454
expect(createSelfHostAi({ AI_PROVIDER: "nonsense" })).toBeUndefined();
5555
});
56+
it("anthropic requires a key; a comma-list builds a fallback chain", () => {
57+
expect(createSelfHostAi({ AI_PROVIDER: "anthropic" })).toBeUndefined(); // no key → dropped
58+
expect(typeof createSelfHostAi({ AI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-ant" })?.run).toBe("function");
59+
// "anthropic,ollama" with a key → both build → a chain (a runnable adapter)
60+
expect(typeof createSelfHostAi({ AI_PROVIDER: "anthropic,ollama", ANTHROPIC_API_KEY: "sk-ant" })?.run).toBe("function");
61+
});
62+
});
63+
64+
describe("createAnthropicAi (#979 native BYOK)", () => {
65+
it("splits the system message and returns the joined text content", async () => {
66+
let sent: { url: string; headers: Record<string, string>; body: Record<string, unknown> } | undefined;
67+
vi.stubGlobal("fetch", vi.fn(async (url: string, init: { headers: Record<string, string>; body: string }) => {
68+
sent = { url, headers: init.headers, body: JSON.parse(init.body) as Record<string, unknown> };
69+
return new Response(JSON.stringify({ content: [{ type: "text", text: "hi" }, { type: "thinking", text: "ignored" }] }), { status: 200 });
70+
}));
71+
const out = await createAnthropicAi({ apiKey: "sk-ant", model: "claude-sonnet-4-6" }).run("@cf/ignored", {
72+
messages: [
73+
{ role: "system", content: "be terse" },
74+
{ role: "user", content: "go" },
75+
],
76+
max_tokens: 256,
77+
});
78+
expect(out.response).toBe("hi"); // only text blocks
79+
expect(sent?.url).toBe("https://api.anthropic.com/v1/messages");
80+
expect(sent?.headers["x-api-key"]).toBe("sk-ant");
81+
expect(sent?.headers["anthropic-version"]).toBe("2023-06-01");
82+
expect(sent?.body.system).toBe("be terse");
83+
expect(sent?.body.model).toBe("claude-sonnet-4-6"); // configured wins over the @cf id
84+
expect(sent?.body.messages).toEqual([{ role: "user", content: "go" }]);
85+
});
86+
});
87+
88+
describe("createChainAi (fallback)", () => {
89+
it("falls through to the next provider on failure, returns the first success", async () => {
90+
const failing = { name: "a", ai: { run: async () => { throw new Error("down"); } } };
91+
const working = { name: "b", ai: { run: async () => ({ response: "from b" }) } };
92+
expect((await createChainAi([failing, working]).run("m", { prompt: "x" })).response).toBe("from b");
93+
});
94+
it("throws the last error when every provider fails", async () => {
95+
const a = { name: "a", ai: { run: async () => { throw new Error("err-a"); } } };
96+
const b = { name: "b", ai: { run: async () => { throw new Error("err-b"); } } };
97+
await expect(createChainAi([a, b]).run("m", { prompt: "x" })).rejects.toThrow(/err-b/);
98+
});
5699
});
57100

58101
describe("subscription CLI helpers + fail-safe", () => {

0 commit comments

Comments
 (0)