Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
caa9ce9
feat: implement backfillReasoningContent function to ensure assistant…
schiz0x00 Aug 8, 2026
c87ed4a
feat: update version to 0.1.1 and enhance tests for catalog refresh h…
schiz0x00 Aug 8, 2026
6bf82f7
fix: give each parallel tool call its own content block
schiz0x00 Aug 8, 2026
285a1f0
fix: keep the upstream error body when retries run out
schiz0x00 Aug 8, 2026
fad07a0
fix: treat a model cache TTL of 0 as startup-refresh-only
schiz0x00 Aug 8, 2026
edb49e9
fix: stop writing to a cancelled stream
schiz0x00 Aug 8, 2026
48c7d93
fix: keep paid models out of the free backend's model list
schiz0x00 Aug 8, 2026
4063715
fix: append context-1m to anthropic-beta instead of replacing it
schiz0x00 Aug 8, 2026
0e033c0
fix: count tool_result payloads in the local token estimate
schiz0x00 Aug 8, 2026
99dc8cf
fix: repeat the stop reason on the usage message_delta
schiz0x00 Aug 8, 2026
9be5604
refactor: single helper for stripping upstream auth headers
schiz0x00 Aug 8, 2026
e0d727e
fix: drop wildcard CORS from the proxy
schiz0x00 Aug 8, 2026
71a9019
fix: accept SSE data lines without the optional space
schiz0x00 Aug 8, 2026
6fb6c75
fix: survive usage-only chunks in the non-anthropic stream targets
schiz0x00 Aug 8, 2026
3c6a363
fix: detect oa-compat error envelopes mid-stream
schiz0x00 Aug 8, 2026
7b91fe5
fix: reject alias ids whose format segment is wrong
schiz0x00 Aug 8, 2026
6d5e7c9
perf: honour the cache timestamp instead of ignoring it
schiz0x00 Aug 8, 2026
34bf8fa
fix: cancel the upstream through its reader, not the locked body
schiz0x00 Aug 8, 2026
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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "claude-opencode-proxy",
"version": "0.1.0",
"version": "0.1.1",
"description": "Anthropic Messages API proxy that translates Claude Code traffic to OpenCode Zen/Go/Free wire formats",
"type": "module",
"license": "MIT",
Expand Down
8 changes: 5 additions & 3 deletions spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ function loadConfig(env: NodeJS.ProcessEnv): Config; // throws on invalid
| `GET` | `/healthz` | liveness → `200 {"status":"ok"}` |
| `GET` | `/ready` | readiness → `200` once config + registry loaded |
| `GET` | `/` | info JSON: version, backend, model count |
| `OPTIONS` | `*` | CORS preflight → `204` with `Access-Control-Allow-*` |
| `OPTIONS` | `*` | not handled → `404`; no CORS headers are sent (see §13.3) |

### 5.1 `GET /v1/models` response shape

Expand Down Expand Up @@ -743,8 +743,10 @@ class ProxyError extends Error {
- Build `hono` app; register routes per §5.
- Global error middleware: catch `ProxyError` → Anthropic envelope; catch
unknown → `500` envelope; log.
- CORS middleware: `OPTIONS *` → `204` with `Access-Control-Allow-Origin: *`,
`-Methods: POST, GET, OPTIONS`, `-Headers: *`.
- No CORS middleware. The clients are CLIs and do not need it, while the proxy
has no authentication of its own: `Access-Control-Allow-Origin: *` would let
any page open in the user's browser POST to the local port and spend the
configured OpenCode key.
- `GET /` info: `{ name, version, backend, modelCount }`.

### 13.4 `router.ts`
Expand Down
28 changes: 8 additions & 20 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { Format } from "./translate/types.js";

/**
* Extract the OpenCode key from a client request (spec §7.4):
* 1. `x-api-key`
Expand All @@ -18,22 +16,12 @@ export function extractApiKey(headers: Headers): string | null {
}

/**
* Inject the key into upstream headers in the format-correct position
* (spec §7.4). `apiKey === undefined` (free backend) → remove all auth
* headers.
* Strip every auth header from an upstream request (spec §7.4). The free
* backend sends no credential at all, and the provider helpers unconditionally
* write one, so this runs after them.
*/
export function injectAuth(headers: Headers, format: Format, apiKey?: string): void {
if (apiKey === undefined) {
headers.delete("x-api-key");
headers.delete("authorization");
headers.delete("x-goog-api-key");
return;
}
if (format === "anthropic" || format === "google") {
headers.set("x-api-key", apiKey);
headers.delete("authorization");
} else {
headers.set("authorization", `Bearer ${apiKey}`);
headers.delete("x-api-key");
}
}
export function clearAuth(headers: Headers): void {
headers.delete("x-api-key");
headers.delete("authorization");
headers.delete("x-goog-api-key");
}
18 changes: 18 additions & 0 deletions src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ export function applyReasoningEffort(
if (options.toggle) upstreamBody.thinking = { type: "enabled" };
}

/**
* DeepSeek-family thinking mode rejects any request whose history contains an
* assistant message without `reasoning_content` once tools are in play
* ("The `reasoning_content` in the thinking mode must be passed back to the
* API.", HTTP 400). Claude Code drops thinking blocks from older turns, so the
* trace is genuinely gone by then — an empty string satisfies the check.
*
* Only fills messages that carry none; real traces translated from `thinking`
* blocks are left untouched. Mutates `upstreamBody` in place.
*/
export function backfillReasoningContent(upstreamBody: Record<string, any>): void {
if (!Array.isArray(upstreamBody.messages)) return;
for (const msg of upstreamBody.messages) {
if (msg?.role !== "assistant") continue;
if (typeof msg.reasoning_content !== "string") msg.reasoning_content = "";
}
}

/**
* Pick the advertised effort value closest to the requested budget. The
* thresholds mirror Claude Code's three tiers; `none`/`minimal` are never
Expand Down
4 changes: 4 additions & 0 deletions src/data/models.static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ const CTX: Record<string, [number, number]> = {
"mimo-v2-omni": [1_048_576, 131_072],
"hy3": [256_000, 64_000],
"hy3-preview": [256_000, 64_000],
// Free-lane ids are deliberately absent: their windows differ from the paid
// sibling's (deepseek-v4-flash is 1M paid, 200K free) and change without
// notice, so they come from `providers.opencode.models.<id>.limit` at
// refresh, never from this table. DEFAULT_CONTEXT covers the cold start.
};

/** Free-tier display names (user-facing in the Claude Code picker). */
Expand Down
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,23 @@ async function refreshModels(): Promise<void> {
baseUrl: config.baseUrl,
cacheFile: config.modelCacheFile,
logger,
maxCacheAgeSeconds: config.modelCacheTtl,
});
} catch (err) {
logger.warn(`model refresh failed: ${(err as Error).message}`);
}
}

void refreshModels();
const refreshTimer = setInterval(refreshModels, config.modelCacheTtl * 1000);
refreshTimer.unref();
// TTL 0 means "startup refresh only". Scheduling it would be setInterval(…, 0),
// which hammers the discovery and catalog endpoints in a tight loop.
const refreshTimer =
config.modelCacheTtl > 0 ? setInterval(refreshModels, config.modelCacheTtl * 1000) : undefined;
refreshTimer?.unref();

function shutdown(signal: string): void {
logger.info(`received ${signal}, shutting down`);
clearInterval(refreshTimer);
if (refreshTimer) clearInterval(refreshTimer);
server.close(() => process.exit(0));
// Force-exit if in-flight requests refuse to drain.
setTimeout(() => process.exit(0), 5000).unref();
Expand Down
71 changes: 59 additions & 12 deletions src/modelRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export interface RegistryRefreshOptions {
logger: Logger;
/** Per-fetch timeout in ms (default 3000). */
timeoutMs?: number;
/**
* Skip the network when the cache on disk is younger than this many seconds.
* Omit to always refresh.
*/
maxCacheAgeSeconds?: number;
}

export interface ModelRegistry {
Expand Down Expand Up @@ -104,6 +109,8 @@ interface CacheFile {
}

interface CatalogMeta {
/** Catalog prices this model at zero, i.e. it is served on the free lane. */
free?: boolean;
contextWindow?: number;
maxOutput?: number;
capabilities?: Partial<Capabilities>;
Expand Down Expand Up @@ -150,7 +157,13 @@ export function createRegistry(backend: Backend): ModelRegistry {
let entry = entries.get(base);
if (!entry) {
const real = fromAliasId(base);
if (real) entry = entries.get(real);
if (real) {
const candidate = entries.get(real);
// The alias carries the wire format. Honouring an alias whose format
// disagrees with the registry would route the request through the
// wrong translator and produce a body the upstream cannot parse.
if (candidate && aliasFormat(base) === candidate.format) entry = candidate;
}
}
if (!entry) return undefined;
return { entry, contextVariant };
Expand All @@ -160,6 +173,14 @@ export function createRegistry(backend: Backend): ModelRegistry {
return `${ALIAS_PREFIX}${entry.format}--${entry.id}`;
}

/** Format segment of an alias id, or undefined when it is not an alias. */
function aliasFormat(alias: string): string | undefined {
if (!alias.startsWith(ALIAS_PREFIX)) return undefined;
const rest = alias.slice(ALIAS_PREFIX.length);
const sep = rest.indexOf("--");
return sep === -1 ? undefined : rest.slice(0, sep);
}

function fromAliasId(alias: string): string | undefined {
if (!alias.startsWith(ALIAS_PREFIX)) return undefined;
const rest = alias.slice(ALIAS_PREFIX.length);
Expand All @@ -168,14 +189,15 @@ export function createRegistry(backend: Backend): ModelRegistry {
return rest.slice(sep + 2);
}

/** Load the cache file; returns entries or undefined on any failure. */
async function loadCache(cacheFile: string): Promise<CacheFile["models"] | undefined> {
/** Load the cache file; returns it or undefined on any failure. */
async function loadCache(cacheFile: string): Promise<CacheFile | undefined> {
try {
const raw = await readFile(expandHome(cacheFile), "utf8");
const parsed = JSON.parse(raw) as CacheFile;
if (parsed.version !== CACHE_VERSION || parsed.backend !== backend) return undefined;
if (!Array.isArray(parsed.models)) return undefined;
return parsed.models;
if (typeof parsed.fetchedAt !== "number") return undefined;
return parsed;
} catch {
return undefined;
}
Expand Down Expand Up @@ -295,7 +317,9 @@ export function createRegistry(backend: Backend): ModelRegistry {
const limit = (v.limit ?? {}) as Record<string, any>;
const modalities = (v.modalities ?? {}) as Record<string, any>;
const input = Array.isArray(modalities.input) ? modalities.input : [];
const cost = (v.cost ?? {}) as Record<string, any>;
return {
free: cost.input === 0 && cost.output === 0,
contextWindow: limit.context,
maxOutput: limit.output,
reasoningOptions: parseReasoningOptions(v.reasoning_options),
Expand Down Expand Up @@ -334,9 +358,16 @@ export function createRegistry(backend: Backend): ModelRegistry {
// the static snapshot so checked-in capability metadata survives; the
// cache fills in last-known context/output and adds discovered ids.
const cached = await loadCache(cacheFile);
if (cached && cached.length > 0) {
applyCache(cached);
logger.debug(`model cache loaded (${cached.length} models)`);
if (cached && cached.models.length > 0) {
applyCache(cached.models);
logger.debug(`model cache loaded (${cached.models.length} models)`);
// `fetchedAt` exists so a restart inside the TTL does not re-download
// discovery plus the multi-megabyte catalog for an answer it already has.
const age = Math.floor(Date.now() / 1000) - cached.fetchedAt;
if (opts.maxCacheAgeSeconds !== undefined && age >= 0 && age < opts.maxCacheAgeSeconds) {
logger.debug(`model cache is ${age}s old, skipping refresh`);
return;
}
}

// 2. Live discovery + catalog metadata.
Expand All @@ -348,10 +379,22 @@ export function createRegistry(backend: Backend): ModelRegistry {
// fall through with whatever we have
}

if (liveIds.length > 0) {
// The free backend shares the Zen base URL, so discovery hands back every
// paid model too. Serving those would put ids in the client's picker that
// the free lane answers with a 401, since it sends no key at all — keep
// only what the catalog prices at zero.
const servable =
backend === "free"
// Drop only what the catalog positively prices as paid. An id the
// catalog has not caught up with yet stays — hiding a model we cannot
// classify is worse than listing one that might 401.
? liveIds.filter((id) => catalog.get(id)?.free !== false)
: liveIds;

if (servable.length > 0) {
// Live ids win: keep existing metadata where known, else defaults.
const merged: Array<Omit<ModelEntry, "capabilities"> & { capabilities?: Partial<Capabilities> }> = [];
for (const id of liveIds) {
for (const id of servable) {
const existing = entries.get(id);
const cat = catalog.get(id);
merged.push({
Expand All @@ -367,12 +410,16 @@ export function createRegistry(backend: Backend): ModelRegistry {
capabilities: { ...(existing?.capabilities ?? {}), ...(cat?.capabilities ?? {}) },
});
}
// Static-only models not seen live are kept (docs may lag the API).
// Snapshot models not seen live are kept (docs may lag the API). Only
// the snapshot — carrying over everything currently in `entries` would
// resurrect ids a stale cache added, including ones discovery just
// filtered out.
const snapshot = new Set(STATIC_MODELS[backend].map((sm) => sm.id));
for (const [id, e] of entries) {
if (!merged.some((m) => m.id === id)) merged.push(e);
if (snapshot.has(id) && !merged.some((m) => m.id === id)) merged.push(e);
}
mergeEntries(merged);
logger.info(`model discovery: ${liveIds.length} live ids, ${entries.size} total`);
logger.info(`model discovery: ${liveIds.length} live ids, ${servable.length} servable, ${entries.size} total`);
} else if (catalog.size > 0) {
// Discovery unavailable (offline, 404, auth): still take catalog
// metadata for the ids we already know, so reasoning options and
Expand Down
41 changes: 23 additions & 18 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Context } from "hono";
import { extractApiKey } from "./auth.js";
import { applyReasoningEffort, stripUnsupported } from "./capability.js";
import { clearAuth, extractApiKey } from "./auth.js";
import { applyReasoningEffort, backfillReasoningContent, stripUnsupported } from "./capability.js";
import type { Config } from "./config.js";
import { ProxyError } from "./errors.js";
import type { Logger } from "./logging.js";
Expand Down Expand Up @@ -84,6 +84,7 @@ export async function handleMessages(c: Context, deps: RouterDeps): Promise<Resp
// conversion); everything else needs the catalog-advertised knob.
if (format !== "anthropic" && entry.capabilities.reasoning) {
applyReasoningEffort(upstreamBody, thinking, entry.reasoningOptions);
backfillReasoningContent(upstreamBody);
}
upstreamBody = provider.modifyBody(upstreamBody);
} catch (err) {
Expand All @@ -105,11 +106,7 @@ export async function handleMessages(c: Context, deps: RouterDeps): Promise<Resp
}
const stickyId = c.req.header("x-claude-code-session-id") ?? "";
provider.modifyHeaders(headers, apiKey ?? "", stickyId);
if (apiKey === undefined) {
headers.delete("x-api-key");
headers.delete("authorization");
headers.delete("x-goog-api-key");
}
if (apiKey === undefined) clearAuth(headers);

const url = provider.modifyUrl(config.baseUrl, isStream);

Expand Down Expand Up @@ -139,10 +136,13 @@ export async function handleMessages(c: Context, deps: RouterDeps): Promise<Resp
if (!upstream.ok) {
const bodyText = await upstream.text();
logger.warn(`upstream ${upstream.status} for model ${modelId}`);
return new Response(bodyText, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
const errorHeaders: Record<string, string> = {
"content-type": upstream.headers.get("content-type") ?? "application/json",
};
// Claude Code waits out a 429 based on this; dropping it makes it guess.
const retryAfter = upstream.headers.get("retry-after");
if (retryAfter) errorHeaders["retry-after"] = retryAfter;
return new Response(bodyText, { status: upstream.status, headers: errorHeaders });
}

if (isStream) {
Expand Down Expand Up @@ -210,11 +210,7 @@ export async function handleCountTokens(c: Context, deps: RouterDeps): Promise<R
supports1m: resolved.entry.contextWindow >= 1_000_000 || resolved.contextVariant === "1m",
});
provider.modifyHeaders(headers, apiKey ?? "", "");
if (apiKey === undefined) {
headers.delete("x-api-key");
headers.delete("authorization");
headers.delete("x-goog-api-key");
}
if (apiKey === undefined) clearAuth(headers);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs);
try {
Expand Down Expand Up @@ -260,6 +256,10 @@ function localEstimate(body: any): Response {
else if (obj.input && typeof obj.input === "object") {
chars += JSON.stringify(obj.input).length;
}
// tool_result nests its payload under `content`, as a string or as
// further blocks. In an agent session that payload is most of the
// context, so skipping it made the estimate useless.
if (obj.content !== undefined) countText(obj.content);
}
};
countText(body?.system);
Expand Down Expand Up @@ -333,8 +333,13 @@ async function fetchWithRetry(
});
if (res.ok || (res.status < 500 && res.status !== 429)) return res;
// Transient upstream failure: drain the body so the socket can be reused.
await res.text().catch(() => undefined);
if (attempt >= maxRetries) return res;
const drained = await res.text().catch(() => "");
// Out of attempts: hand back the text we drained. Returning `res` itself
// would give the caller a consumed body, and reading it again throws —
// turning a 429 the client knows how to back off from into a 500.
if (attempt >= maxRetries) {
return new Response(drained, { status: res.status, headers: res.headers });
}
logger.warn(`upstream ${res.status}, retrying (${attempt + 1}/${maxRetries})`);
} catch (err) {
lastErr = err as Error;
Expand Down
14 changes: 4 additions & 10 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { Config } from "./config.js";
import { anthropicError, ProxyError } from "./errors.js";
import { registerHealth } from "./health.js";
Expand All @@ -24,15 +23,10 @@ export function createApp(deps: ServerDeps): Hono {
const { config, logger } = deps;
const app = new Hono();

app.use(
"*",
cors({
origin: "*",
allowMethods: ["POST", "GET", "OPTIONS"],
allowHeaders: ["*"],
maxAge: 86_400,
}),
);
// No CORS headers on purpose. The clients are CLIs, which do not need them,
// and this proxy has no authentication of its own: with `Access-Control-
// Allow-Origin: *` any page in the user's browser could POST to
// 127.0.0.1:8787 and spend the configured OpenCode key.

registerHealth(app, deps);

Expand Down
Loading
Loading