auto-model-router 0.4.14 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.4.14",
10
+ "version": "0.5.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.4.14",
17
+ "version": "0.5.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1184,6 +1184,51 @@ text, not the conversation, so a hard task that only becomes hard three tool
1184
1184
  calls in stays on the router (the router's own escalation still applies
1185
1185
  there); and the switch happens at prompt boundaries, never mid-turn.
1186
1186
 
1187
+ ## Claude Code (Anthropic Messages API)
1188
+
1189
+ The router also speaks the Anthropic Messages API, which is the only wire Claude Code
1190
+ uses. Point Claude Code at the router and every turn is routed like any other harness's,
1191
+ to OpenRouter, Ollama Cloud, or whatever upstream is configured:
1192
+
1193
+ ```bash
1194
+ export ANTHROPIC_BASE_URL=http://127.0.0.1:8788
1195
+ export ANTHROPIC_API_KEY=<server.apiKey, or any string when the router has no key>
1196
+ claude
1197
+ ```
1198
+
1199
+ `POST /v1/messages` (streaming and not) and `POST /v1/messages/count_tokens` are served;
1200
+ the key may arrive as `x-api-key` or as a bearer. Claude Code asks for `claude-*` model
1201
+ names, which `anthropic.models` maps to profiles (first matching glob wins):
1202
+
1203
+ ```yaml
1204
+ anthropic:
1205
+ models:
1206
+ "*haiku*": auto-cheap # Claude Code's background chores
1207
+ "claude-*": auto # real turns; try auto-max for an opus-only feel
1208
+ ```
1209
+
1210
+ Profile ids pass through, so `ANTHROPIC_MODEL=auto-max` works too. The harness id defaults
1211
+ to `claude-code` (from the user agent) and the session id is taken from the `metadata`
1212
+ Claude Code sends, so reports, budgets and the team edition see it like any other harness.
1213
+
1214
+ What is translated: system prompts (string or blocks), text, image, `tool_use` and
1215
+ `tool_result` blocks, custom tools and `tool_choice` (including
1216
+ `disable_parallel_tool_use`), `stop_sequences`, `thinking` budgets and `output_config.effort`
1217
+ (as reasoning effort), and back: text, `tool_use` and `thinking` blocks, the four stop
1218
+ reasons, and usage with cache read and cache creation counts. The routing summary rides on
1219
+ `message_delta` as `x_auto_model_router`.
1220
+
1221
+ Not available through the router: Anthropic server-side tools (web search, web fetch, code
1222
+ execution) and Anthropic-schema client tools (`bash_*`, `text_editor_*`) are dropped from
1223
+ the tool list, since no upstream serves them; thinking blocks come back unsigned and are
1224
+ dropped again on replay; `count_tokens` is the router's own estimate. Client `cache_control`
1225
+ markers are replaced by the router's own breakpoint plan.
1226
+
1227
+ Claude Code prices its own cost line from the Claude model name it asked for, so the
1228
+ figure it shows is not what was spent; the router's ledger (`/router report`, the team
1229
+ edition) is. Verified with Claude Code 2.1.263 headless (`claude -p` with the Read tool)
1230
+ routed to a DeepSeek model; the captured request is `test/fixtures/harness/claude-code.json`.
1231
+
1187
1232
  ## Per-request routing policy
1188
1233
 
1189
1234
  A front door in front of the router (the team edition, or any proxy that
@@ -1229,6 +1274,7 @@ plus a harness header; the rest needs the harness's own hook API.
1229
1274
  | Harness | Wire | Harness id | Session id | Subagent flag | Toast | `/router` | Digest | Daily summary | Model switch |
1230
1275
  | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
1231
1276
  | omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
1277
+ | Claude Code | Anthropic Messages (`/v1/messages`) | derived (`claude-code`) | from `metadata` | no | no | no | no | no | no |
1232
1278
  | Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
1233
1279
  | Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
1234
1280
  | Aider | config only | via model settings | no | no | no | no | no tools | no | no |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.4.14",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -335,6 +335,13 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
335
335
  { path: "report.dailySummary", label: "Daily summary at session start", kind: "boolean" },
336
336
  ],
337
337
  },
338
+ {
339
+ title: "Claude Code (Anthropic Messages wire)",
340
+ fields: [
341
+ { path: "anthropic.models.*haiku*", label: "Model names matching *haiku* route to profile", kind: "string", optional: true, hint: "e.g. auto-cheap" },
342
+ { path: "anthropic.models.claude-*", label: "Model names matching claude-* route to profile", kind: "string", optional: true, hint: "e.g. auto or auto-max" },
343
+ ],
344
+ },
338
345
  {
339
346
  title: "Harness switch",
340
347
  fields: [
@@ -335,6 +335,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
335
335
  // One transcript message per day, at the first interactive session start.
336
336
  dailySummary: true,
337
337
  },
338
+ anthropic: {
339
+ // haiku is what Claude Code uses for background chores; everything else is a real turn.
340
+ models: { "*haiku*": "auto-cheap", "claude-*": "auto" },
341
+ },
338
342
  harnessSwitch: {
339
343
  // Off: moving the harness's own model is a visible change the operator opts into.
340
344
  enabled: false,
@@ -277,6 +277,7 @@ export const configInputSchema = z.strictObject({
277
277
  budget: budget.optional(),
278
278
  profiles: z.array(profile).optional(),
279
279
  report: z.strictObject({ baselines: z.array(z.string()).optional(), dailySummary: z.boolean().optional() }).optional(),
280
+ anthropic: z.object({ models: z.record(z.string(), z.string()).optional() }).strict().optional(),
280
281
  harnessSwitch: z
281
282
  .strictObject({
282
283
  enabled: z.boolean().optional(),
@@ -620,6 +620,16 @@ export interface ReportConfig {
620
620
  * each user prompt and the harness moves its own active model to a
621
621
  * harness-native one for the mapped tiers. See omp-extension/router-switch.ts.
622
622
  */
623
+ /** The Anthropic Messages wire (`POST /v1/messages`, Claude Code). */
624
+ export interface AnthropicConfig {
625
+ /**
626
+ * Which router profile a Messages `model` name means, first matching glob
627
+ * wins. Claude Code asks for `claude-*` names; unmatched names pass through
628
+ * so profile ids (`auto`, `auto-max`) still work.
629
+ */
630
+ models: Record<string, string>;
631
+ }
632
+
623
633
  export interface HarnessSwitchConfig {
624
634
  enabled: boolean;
625
635
  /**
@@ -830,6 +840,7 @@ export interface RouterConfig {
830
840
  report: ReportConfig;
831
841
  digest: DigestConfig;
832
842
  harnessSwitch: HarnessSwitchConfig;
843
+ anthropic: AnthropicConfig;
833
844
  profiles: ProfileConfig[];
834
845
  ledger: LedgerConfig;
835
846
  /**
@@ -11,6 +11,7 @@ import { advise } from "./advise.ts";
11
11
  import { TIER_ORDER, type Tier } from "../router/types.ts";
12
12
  import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
13
13
  import { exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
14
+ import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
14
15
  import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
15
16
  import type { Ledger, ModelTrust } from "../cost/types.ts";
16
17
  import { createRouter } from "../router/index.ts";
@@ -334,9 +335,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
334
335
  parse(body: unknown, headers: Headers): NormRequest;
335
336
  streaming(model: string): { sink: ResponseSink; response: Response };
336
337
  buffered(model: string): { sink: ResponseSink; response: Promise<Response> };
338
+ /** How a failure before the sink exists is rendered; the OpenAI envelope when absent. */
339
+ error?: (err: WireError) => Response;
337
340
  }
338
341
  const CHAT_WIRE: Wire = { parse: parseChatRequest, streaming: createStreamingSink, buffered: createBufferedSink };
339
342
  const RESPONSES_WIRE: Wire = { parse: parseResponsesRequest, streaming: createResponsesStreamingSink, buffered: createResponsesBufferedSink };
343
+ const MESSAGES_WIRE: Wire = createMessagesWire(cfg.anthropic.models);
340
344
 
341
345
  const handleTurn = async (req: Request, wire: Wire): Promise<Response> => {
342
346
  let normReq: NormRequest;
@@ -347,8 +351,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
347
351
  // runTurn's `finally`, so it must be released here or every malformed
348
352
  // request permanently consumes one of maxConcurrentTurns.
349
353
  releaseTurn();
350
- if (err instanceof WireErrorException) return wireErrorResponse(err.wireError);
351
- return wireErrorResponse({
354
+ const render = wire.error ?? wireErrorResponse;
355
+ if (err instanceof WireErrorException) return render(err.wireError);
356
+ return render({
352
357
  status: 400,
353
358
  code: "invalid_json",
354
359
  message: err instanceof Error ? err.message : "request body is not valid JSON",
@@ -402,13 +407,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
402
407
  return wireErrorResponse({ status: 403, code: "forbidden", message: "invalid host" });
403
408
  }
404
409
 
410
+ const url = new URL(req.url);
411
+ // The Messages wire renders its own error envelope; everything else speaks OpenAI's.
412
+ const errorResponse = url.pathname.startsWith("/v1/messages") ? anthropicErrorResponse : wireErrorResponse;
405
413
  if (cfg.server.apiKey !== undefined && cfg.server.apiKey !== "") {
406
- if (req.headers.get("authorization") !== `Bearer ${cfg.server.apiKey}`) {
407
- return wireErrorResponse({ status: 401, code: "unauthorized", message: "invalid or missing bearer token" });
408
- }
414
+ // Anthropic clients (Claude Code) present the key as x-api-key rather than a bearer.
415
+ const presented = req.headers.get("authorization") === `Bearer ${cfg.server.apiKey}` || req.headers.get("x-api-key") === cfg.server.apiKey;
416
+ if (!presented) return errorResponse({ status: 401, code: "unauthorized", message: "invalid or missing API key" });
409
417
  }
410
418
 
411
- const url = new URL(req.url);
412
419
  try {
413
420
  if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
414
421
  if (!acquireTurn()) {
@@ -423,6 +430,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
423
430
  }
424
431
  return await handleTurn(req, RESPONSES_WIRE);
425
432
  }
433
+ if (req.method === "POST" && url.pathname === "/v1/messages") {
434
+ if (!acquireTurn()) {
435
+ return anthropicErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
436
+ }
437
+ return await handleTurn(req, MESSAGES_WIRE);
438
+ }
439
+ if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
440
+ try {
441
+ return json({ input_tokens: countAnthropicTokens(await req.json(), cfg.anthropic.models, ledger) });
442
+ } catch (err) {
443
+ if (err instanceof WireErrorException) return anthropicErrorResponse(err.wireError);
444
+ return anthropicErrorResponse({ status: 400, code: "invalid_json", message: err instanceof Error ? err.message : "request body is not valid JSON" });
445
+ }
446
+ }
426
447
  if (req.method === "GET" && url.pathname === "/v1/models") {
427
448
  return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
428
449
  }