Skip to content
Merged
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
5 changes: 5 additions & 0 deletions scripts/check-latest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export async function run(routesPath = "routes.toml"): Promise<number> {
return 1;
}

if (liveIds.length === 0) {
console.error("[check-latest] OpenRouter returned an empty/unrecognized catalog; cannot verify slugs.");
return 1;
}

const rows = analyzeModels(configured, liveIds);
let stale = 0;
console.log(`Checked ${rows.length} OpenRouter model(s) against ${liveIds.length} live catalog entries:\n`);
Expand Down
72 changes: 42 additions & 30 deletions scripts/record-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,55 @@
import { mkdirSync, writeFileSync } from "node:fs";
import process from "node:process";
import { extractSignals } from "../src/signals.ts";

// Record real Claude Code requests for hermetic replay.
// Usage: bun run scripts/record-fixtures.ts (then run `claude -p ...` against it)
import { extractSignals } from "../src/signals.ts";

mkdirSync("test/fixtures", { recursive: true });
let n = 0;
const REDACT = ["authorization", "x-api-key", "cookie"];

Bun.serve({
port: Number(process.env.PORT ?? 8787),
idleTimeout: 0,
async fetch(req) {
const body = await req.json();
const s = extractSignals(req.headers, body);
const name = `${String(++n).padStart(2, "0")}-${s.isSubagent ? "sub" : "main"}-${s.tag ?? "none"}`;
writeFileSync(
`test/fixtures/${name}.json`,
JSON.stringify(
{ headers: Object.fromEntries(req.headers), body },
null,
2,
),
);
process.stderr.write(`recorded ${name}\n`);
// Forward to real Anthropic so the session keeps working while recording.
const url = new URL(req.url);
const upstream = await fetch(`https://api.anthropic.com${url.pathname}${url.search}`, {
method: req.method,
headers: stripHop(req.headers),
body: JSON.stringify(body),
});
return new Response(upstream.body, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" } });
},
});
// Never persist real Claude Code credentials into a (git-tracked) fixture.
export function redactHeaders(headers: Record<string, string>): Record<string, string> {
const out = { ...headers };
for (const k of REDACT) {
if (k in out)
out[k] = "REDACTED";
}
return out;
}

function stripHop(h: Headers): Headers {
const out = new Headers(h);
for (const k of ["host", "content-length", "connection", "accept-encoding"]) out.delete(k);
return out;
}

console.error(`recorder on :${process.env.PORT ?? 8787}`);
function startRecorder(): void {
mkdirSync("test/fixtures", { recursive: true });
let n = 0;
Bun.serve({
port: Number(process.env.PORT ?? 8787),
idleTimeout: 0,
async fetch(req) {
const body = await req.json();
const s = extractSignals(req.headers, body);
const name = `${String(++n).padStart(2, "0")}-${s.isSubagent ? "sub" : "main"}-${s.tag ?? "none"}`;
writeFileSync(
`test/fixtures/${name}.json`,
JSON.stringify({ headers: redactHeaders(Object.fromEntries(req.headers)), body }, null, 2),
);
process.stderr.write(`recorded ${name}\n`);
// Forward to real Anthropic so the session keeps working while recording.
const url = new URL(req.url);
const upstream = await fetch(`https://api.anthropic.com${url.pathname}${url.search}`, {
method: req.method,
headers: stripHop(req.headers),
body: JSON.stringify(body),
});
return new Response(upstream.body, { status: upstream.status, headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" } });
},
});
console.error(`recorder on :${process.env.PORT ?? 8787}`);
}

if (import.meta.main)
startRecorder();
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function runCli(argv: string[]): Promise<number> {
}
if (cmd === "check-latest") {
const { run } = await import("../scripts/check-latest.ts");
return run();
return run(ROUTES); // honor MUX_ROUTES like every other command
}
console.log("commands: serve | models | set <alias> <upstream:slug> | use <agent> <alias> | check-latest");
return 0;
Expand Down
33 changes: 31 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import type { Config, ModelRef, Upstream } from "./types.ts";
import type { Config, ModelRef, RouteRule, Upstream, WorkType } from "./types.ts";
import { readFileSync, watch } from "node:fs";
import process from "node:process";

const UPSTREAMS: Upstream[] = ["anthropic", "openrouter"];
const WORK_TYPES = new Set<WorkType>(["background", "think", "longContext", "webSearch"]);

// A route's `when` must name exactly one recognized condition. Reject anything
// else at load so a typo'd/empty/ambiguous rule fails loud here instead of
// silently never matching (which would also defeat the hot-reload keep-last-good
// safety net, since loadConfig succeeding is what lets a bad config swap in).
function validateWhen(when: RouteRule["when"] | undefined): void {
if (!when || typeof when !== "object")
throw new Error("a route is missing its `when` clause");
const active = (["tag", "workType", "anySubagent"] as const).filter(k => when[k] !== undefined);
if (active.length === 0)
throw new Error(`a route \`when\` names no condition (need tag / workType / anySubagent): ${JSON.stringify(when)}`);
if (active.length > 1)
throw new Error(`a route \`when\` must name exactly one condition, got: ${active.join(" + ")}`);
if (when.workType !== undefined && !WORK_TYPES.has(when.workType))
throw new Error(`unknown workType "${when.workType}" (expected one of: ${[...WORK_TYPES].join(" | ")})`);
if (when.anySubagent !== undefined && when.anySubagent !== true)
throw new Error("route `when.anySubagent` must be true when present");
}

export function parseModelRef(spec: string): ModelRef {
const idx = spec.indexOf(":");
Expand All @@ -22,8 +41,13 @@ export function resolveMenu(
env: Record<string, string | undefined>,
): Config {
const models = { ...config.models };
const claimedBy = new Map<string, string>(); // MUX_MODEL_ key -> alias that owns it
for (const alias of Object.keys(models)) {
const key = `MUX_MODEL_${alias.toUpperCase().replace(/-/g, "_")}`;
const prior = claimedBy.get(key);
if (prior !== undefined && env[key])
throw new Error(`aliases "${prior}" and "${alias}" both map to ${key}; rename one to avoid an ambiguous override`);
claimedBy.set(key, alias);
const override = env[key];
if (override)
models[alias] = parseModelRef(override);
Expand All @@ -43,6 +67,8 @@ export function loadConfig(
env: Record<string, string | undefined> = process.env,
): Config {
const raw = Bun.TOML.parse(readText(path)) as unknown as RawConfig;
if (!raw.models || typeof raw.models !== "object")
throw new Error("routes.toml is missing a [models] table");
const models: Record<string, ModelRef> = {};
for (const [alias, spec] of Object.entries(raw.models)) {
models[alias] = parseModelRef(spec);
Expand All @@ -53,11 +79,14 @@ export function loadConfig(
routes: raw.routes ?? [],
longContextThreshold: raw.longContextThreshold ?? 200000,
};
if (!config.default)
throw new Error("routes.toml is missing a `default` alias");
if (!models[config.default]) {
throw new Error(`default alias "${config.default}" not in models`);
}
// Fail loud at load time: every route's target alias must exist.
// Fail loud at load time: each route needs a valid `when` and a known target alias.
for (const r of config.routes) {
validateWhen(r.when);
if (!models[r.use])
throw new Error(`route uses unknown alias "${r.use}" (not in models)`);
}
Expand Down
25 changes: 22 additions & 3 deletions src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import type { Decision, Signals } from "./types.ts";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import process from "node:process";

// Best-effort append: a logging I/O failure (bad MUX_LOG path, read-only cwd,
// full disk) is observability, not correctness β€” it must never crash a request
// that would otherwise route fine.
function tryAppend(path: string, line: string): void {
try {
appendFileSync(path, line);
}
catch {
// swallow β€” do not let a log write take down the proxy
}
}

export function logDecision(path: string, signals: Signals, decision: Decision): void {
const record = {
ts: new Date().toISOString(),
Expand All @@ -15,7 +27,7 @@ export function logDecision(path: string, signals: Signals, decision: Decision):
upstream: decision.upstream,
resolvedModel: decision.model,
};
appendFileSync(path, `${JSON.stringify(record)}\n`);
tryAppend(path, `${JSON.stringify(record)}\n`);
process.stderr.write(
`[route] ${decision.matchedRule} -> ${decision.upstream}:${decision.model}`
+ ` (sub=${signals.isSubagent})\n`,
Expand All @@ -32,7 +44,7 @@ export function logError(path: string, signals: Signals, err: Error): void {
matchedRule: "error",
error: err.message,
};
appendFileSync(path, `${JSON.stringify(record)}\n`);
tryAppend(path, `${JSON.stringify(record)}\n`);
process.stderr.write(`[route] ERROR ${err.message}\n`);
}

Expand All @@ -42,5 +54,12 @@ export function readDecisions(path: string): any[] {
return readFileSync(path, "utf8")
.split("\n")
.filter(l => l.trim().length > 0)
.map(l => JSON.parse(l));
.flatMap((l) => {
try {
return [JSON.parse(l)];
}
catch {
return []; // skip a truncated/partial final line rather than losing every record
}
});
}
2 changes: 1 addition & 1 deletion src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function route(signals: Signals, config: Config): Decision {
function matches(rule: RouteRule, s: Signals, config: Config): boolean {
const w = rule.when;
if (w.tag !== undefined)
return s.tag === w.tag;
return s.tag === w.tag.toLowerCase(); // signal tag is already lowercased; match config case-insensitively
if (w.workType !== undefined)
return hasWorkType(s, w.workType, config);
if (w.anySubagent === true)
Expand Down
29 changes: 19 additions & 10 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { watchConfig } from "./config.ts";
import { logDecision, logError } from "./log.ts";
import { route } from "./route.ts";
import { extractSignals } from "./signals.ts";
import { forwardUrl, rewriteBody, rewriteHeaders } from "./upstreams.ts";
import { forwardUrl, passthroughHeaders, rewriteBody, rewriteHeaders } from "./upstreams.ts";

export interface ServerOpts {
config?: Config; // static config (tests); ignored if configHolder is set
Expand Down Expand Up @@ -60,18 +60,27 @@ export function buildServer(opts: ServerOpts): Bun.Server<never> {
? base + url.pathname + url.search
: forwardUrl(decision.upstream, url.pathname, url.search);

const upstream = await fetch(target, {
method: req.method,
headers,
body: JSON.stringify(body),
});
let upstream: Response;
try {
upstream = await fetch(target, {
method: req.method,
headers,
body: JSON.stringify(body),
signal: req.signal, // propagate client cancellation so we don't keep billing
});
}
catch (e) {
// Client hung up before the upstream answered β€” nothing left to reply to.
if ((e as Error).name === "AbortError")
return new Response(null, { status: 499 });
// Upstream unreachable (DNS/refused/TLS/offline): fail loud + log, never a silent 500.
logError(opts.logPath, signals, e as Error);
return new Response(`upstream fetch failed: ${(e as Error).message}`, { status: 502 });
}

return new Response(upstream.body, {
status: upstream.status,
headers: {
"content-type": upstream.headers.get("content-type") ?? "application/json",
"cache-control": "no-cache",
},
headers: passthroughHeaders(upstream.headers),
});
},
});
Expand Down
25 changes: 25 additions & 0 deletions src/upstreams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,28 @@ export function rewriteBody(decision: Decision, body: any): any {
body.model = decision.model;
return body;
}

// Framing headers that describe the *upstream* transfer β€” Bun's fetch already
// decoded the body and will re-frame our streamed Response, so copying these
// would double-decode or mis-length the reply.
const STRIP_RESPONSE = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);

// Build downstream response headers from the upstream ones, so rate-limit /
// retry-after / request-id survive (Claude Code honors them for backoff), while
// stale framing headers are dropped and caching is disabled. Multi-value
// set-cookie is preserved.
export function passthroughHeaders(upstream: Headers): Headers {
const out = new Headers();
for (const [k, v] of upstream) {
const key = k.toLowerCase();
if (STRIP_RESPONSE.has(key) || key === "set-cookie")
continue;
out.set(k, v);
}
for (const c of upstream.getSetCookie?.() ?? [])
out.append("set-cookie", c);
if (!out.has("content-type"))
out.set("content-type", "application/json");
out.set("cache-control", "no-cache");
return out;
}
Loading