auto-model-router 0.4.13 → 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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +52 -0
- package/package.json +1 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +7 -0
- package/src/cli/export.ts +29 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +11 -0
- package/src/cost/views.ts +153 -0
- package/src/index.ts +5 -0
- package/src/lib.ts +1 -0
- package/src/server/http.ts +52 -9
- package/src/wire/anthropic/messages.ts +481 -0
- package/src/wire/types.ts +1 -1
- package/test/anthropic-wire.test.ts +287 -0
- package/test/failover.test.ts +1 -0
- package/test/fixtures/harness/claude-code.json +858 -0
- package/test/turn.test.ts +1 -0
- package/test/views.test.ts +161 -0
|
@@ -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.
|
|
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.
|
|
17
|
+
"version": "0.5.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -706,6 +706,12 @@ escalation signal, error. Three views aggregate it, all from the same
|
|
|
706
706
|
transcript. Falls back to reading the ledger directly if the router is
|
|
707
707
|
unreachable.
|
|
708
708
|
- `auto-model-router report --days 7 [--harness <id>] [--json]` on the terminal.
|
|
709
|
+
- `auto-model-router export --days 30 [--harness a,b] [--json]`: one row per day, harness
|
|
710
|
+
and model (dispatches, tokens, spend, escalations, errors) as CSV. Also
|
|
711
|
+
`GET /v1/router/export?days=&harness=[&format=json]`; `GET /v1/router/spend?sinceMs=&harness=`
|
|
712
|
+
gives spend over a harness set since an instant, and `GET /v1/router/feedback?days=&harness=`
|
|
713
|
+
lists verdicts by model and the recent ones with the harness that gave them. These are what
|
|
714
|
+
a front door such as the team edition reads instead of the ledger file.
|
|
709
715
|
- `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
|
|
710
716
|
a comma-separated set of ids, for a group).
|
|
711
717
|
- `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
|
|
@@ -1178,6 +1184,51 @@ text, not the conversation, so a hard task that only becomes hard three tool
|
|
|
1178
1184
|
calls in stays on the router (the router's own escalation still applies
|
|
1179
1185
|
there); and the switch happens at prompt boundaries, never mid-turn.
|
|
1180
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
|
+
|
|
1181
1232
|
## Per-request routing policy
|
|
1182
1233
|
|
|
1183
1234
|
A front door in front of the router (the team edition, or any proxy that
|
|
@@ -1223,6 +1274,7 @@ plus a harness header; the rest needs the harness's own hook API.
|
|
|
1223
1274
|
| Harness | Wire | Harness id | Session id | Subagent flag | Toast | `/router` | Digest | Daily summary | Model switch |
|
|
1224
1275
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
1225
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 |
|
|
1226
1278
|
| Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
|
|
1227
1279
|
| Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
|
|
1228
1280
|
| Aider | config only | via model settings | no | no | no | no | no tools | no | no |
|
package/package.json
CHANGED
package/src/cli/args.ts
CHANGED
package/src/cli/config-wizard.ts
CHANGED
|
@@ -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: [
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `auto-model-router export`: the ledger as one row per day, harness and
|
|
3
|
+
* model (dispatches, tokens, spend, escalations, errors), CSV by default or
|
|
4
|
+
* `--json`. The same rows back `GET /v1/router/export`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { loadConfig } from "../config/load.ts";
|
|
10
|
+
import { exportCsv, exportRows } from "../cost/views.ts";
|
|
11
|
+
import { configOpts, flagInt, flagString, type CliArgs } from "./args.ts";
|
|
12
|
+
|
|
13
|
+
export async function exportCommand(args: CliArgs): Promise<void> {
|
|
14
|
+
const days = flagInt(args, "days") ?? 30;
|
|
15
|
+
const harness = flagString(args, "harness") ?? "";
|
|
16
|
+
const cfg = loadConfig(configOpts(args));
|
|
17
|
+
if (!existsSync(cfg.ledger.path)) {
|
|
18
|
+
process.stdout.write(args.flags.has("json") ? "[]\n" : exportCsv([]));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Read-only: an export must never create or migrate the ledger.
|
|
22
|
+
const db = new Database(cfg.ledger.path, { readonly: true });
|
|
23
|
+
try {
|
|
24
|
+
const rows = exportRows(db, Date.now() - days * 86_400_000, harness === "" ? null : harness.split(",").map((s) => s.trim()).filter((s) => s !== ""));
|
|
25
|
+
process.stdout.write(args.flags.has("json") ? `${JSON.stringify(rows, null, 2)}\n` : exportCsv(rows));
|
|
26
|
+
} finally {
|
|
27
|
+
db.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -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,
|
package/src/config/schema.ts
CHANGED
|
@@ -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(),
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger views that a front door (the team edition, a dashboard, a script)
|
|
3
|
+
* needs at a scope the report does not offer: spend since an instant over a
|
|
4
|
+
* set of harnesses, feedback verdicts with the harness that gave them, and a
|
|
5
|
+
* cost export by day, harness and model. Served by `/v1/router/spend`,
|
|
6
|
+
* `GET /v1/router/feedback` and `/v1/router/export`, exported from lib.ts, and
|
|
7
|
+
* behind `auto-model-router export`. All of them read only long-stable ledger
|
|
8
|
+
* columns and accept a read-only database handle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import { harnessFilter } from "./report.ts";
|
|
13
|
+
|
|
14
|
+
/** `null` means every harness; an empty set matches nothing. */
|
|
15
|
+
export type HarnessScope = readonly string[] | null;
|
|
16
|
+
|
|
17
|
+
export interface FeedbackRow {
|
|
18
|
+
atMs: number;
|
|
19
|
+
slug: string;
|
|
20
|
+
tier: string;
|
|
21
|
+
verdict: "good" | "bad";
|
|
22
|
+
note: string;
|
|
23
|
+
/** The harness id of the judged turn (a team user id), or empty. */
|
|
24
|
+
harnessId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FeedbackByModel {
|
|
28
|
+
slug: string;
|
|
29
|
+
good: number;
|
|
30
|
+
bad: number;
|
|
31
|
+
/** Distinct harness ids that judged this model. */
|
|
32
|
+
judges: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FeedbackView {
|
|
36
|
+
byModel: FeedbackByModel[];
|
|
37
|
+
recent: FeedbackRow[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExportRow {
|
|
41
|
+
day: string;
|
|
42
|
+
harnessId: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
provider: string;
|
|
45
|
+
dispatches: number;
|
|
46
|
+
promptTokens: number;
|
|
47
|
+
cachedTokens: number;
|
|
48
|
+
completionTokens: number;
|
|
49
|
+
spendUsd: number;
|
|
50
|
+
escalations: number;
|
|
51
|
+
errors: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const USD = "COALESCE(reported_usd, predicted_usd)";
|
|
55
|
+
|
|
56
|
+
function scope(harness: HarnessScope, column: string): { sql: string[]; bind: Record<string, string> } | null {
|
|
57
|
+
if (harness === null) return { sql: [], bind: {} };
|
|
58
|
+
if (harness.length === 0) return null;
|
|
59
|
+
const f = harnessFilter(harness.join(","));
|
|
60
|
+
return { sql: f.sql.map((s) => s.replace(/^harness_id/, column)), bind: f.bind };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Spend (reported where present, predicted otherwise) since `sinceMs`, digest calls included as the ledger counts them. */
|
|
64
|
+
export function spendUsdSince(db: Database, sinceMs: number, harness: HarnessScope): number {
|
|
65
|
+
const s = scope(harness, "harness_id");
|
|
66
|
+
if (s === null) return 0;
|
|
67
|
+
const where = ["created_at_ms >= $since", ...s.sql].join(" AND ");
|
|
68
|
+
const row = db.query(`SELECT COALESCE(SUM(${USD}), 0) AS usd FROM ledger WHERE ${where}`).get({ $since: sinceMs, ...s.bind }) as { usd: number };
|
|
69
|
+
return row.usd;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Verdicts since `sinceMs`, by model and the most recent 200, joined to the ledger for the judging harness. */
|
|
73
|
+
export function feedbackView(db: Database, sinceMs: number, harness: HarnessScope): FeedbackView {
|
|
74
|
+
const exists = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'feedback'").get() as { name: string } | null) !== null;
|
|
75
|
+
if (!exists) return { byModel: [], recent: [] };
|
|
76
|
+
const s = scope(harness, "l.harness_id");
|
|
77
|
+
if (s === null) return { byModel: [], recent: [] };
|
|
78
|
+
const where = ["f.created_at_ms >= $since", ...s.sql].join(" AND ");
|
|
79
|
+
const bind = { $since: sinceMs, ...s.bind };
|
|
80
|
+
const recent = (
|
|
81
|
+
db.query(`SELECT f.created_at_ms AS at_ms, f.slug, f.tier, f.verdict, f.note, COALESCE(l.harness_id, '') AS harness_id FROM feedback f LEFT JOIN ledger l ON l.id = f.ledger_id WHERE ${where} ORDER BY f.created_at_ms DESC LIMIT 200`).all(bind) as {
|
|
82
|
+
at_ms: number;
|
|
83
|
+
slug: string;
|
|
84
|
+
tier: string;
|
|
85
|
+
verdict: string;
|
|
86
|
+
note: string;
|
|
87
|
+
harness_id: string;
|
|
88
|
+
}[]
|
|
89
|
+
).map((r) => ({ atMs: r.at_ms, slug: r.slug, tier: r.tier, verdict: (r.verdict === "good" ? "good" : "bad") as "good" | "bad", note: r.note, harnessId: r.harness_id }));
|
|
90
|
+
const byModel = (
|
|
91
|
+
db
|
|
92
|
+
.query(
|
|
93
|
+
`SELECT f.slug, SUM(CASE WHEN f.verdict = 'good' THEN 1 ELSE 0 END) AS good, SUM(CASE WHEN f.verdict = 'bad' THEN 1 ELSE 0 END) AS bad, COUNT(DISTINCT COALESCE(l.harness_id, '')) AS judges
|
|
94
|
+
FROM feedback f LEFT JOIN ledger l ON l.id = f.ledger_id WHERE ${where} GROUP BY f.slug ORDER BY bad DESC, good DESC, f.slug ASC`,
|
|
95
|
+
)
|
|
96
|
+
.all(bind) as { slug: string; good: number; bad: number; judges: number }[]
|
|
97
|
+
).map((r) => ({ slug: r.slug, good: r.good, bad: r.bad, judges: r.judges }));
|
|
98
|
+
return { byModel, recent };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One row per UTC day, harness and served model since `sinceMs`; digest calls are excluded as in the report. */
|
|
102
|
+
export function exportRows(db: Database, sinceMs: number, harness: HarnessScope): ExportRow[] {
|
|
103
|
+
const s = scope(harness, "harness_id");
|
|
104
|
+
if (s === null) return [];
|
|
105
|
+
const where = ["created_at_ms >= $since", "requested_model <> 'digest'", ...s.sql].join(" AND ");
|
|
106
|
+
const rows = db
|
|
107
|
+
.query(
|
|
108
|
+
`SELECT strftime('%Y-%m-%d', created_at_ms / 1000, 'unixepoch') AS day, harness_id, COALESCE(served_slug, slug) AS slug,
|
|
109
|
+
COUNT(*) AS dispatches,
|
|
110
|
+
COALESCE(SUM(json_extract(usage, '$.promptTokens')), 0) AS prompt_tokens,
|
|
111
|
+
COALESCE(SUM(json_extract(usage, '$.cachedTokens')), 0) AS cached_tokens,
|
|
112
|
+
COALESCE(SUM(json_extract(usage, '$.completionTokens')), 0) AS completion_tokens,
|
|
113
|
+
COALESCE(SUM(${USD}), 0) AS spend,
|
|
114
|
+
SUM(CASE WHEN escalation_signal IS NOT NULL THEN 1 ELSE 0 END) AS escalations,
|
|
115
|
+
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS errors
|
|
116
|
+
FROM ledger WHERE ${where} GROUP BY day, harness_id, slug ORDER BY day ASC, harness_id ASC, spend DESC`,
|
|
117
|
+
)
|
|
118
|
+
.all({ $since: sinceMs, ...s.bind }) as { day: string; harness_id: string; slug: string; dispatches: number; prompt_tokens: number; cached_tokens: number; completion_tokens: number; spend: number; escalations: number; errors: number }[];
|
|
119
|
+
return rows.map((r) => ({
|
|
120
|
+
day: r.day,
|
|
121
|
+
harnessId: r.harness_id,
|
|
122
|
+
slug: r.slug,
|
|
123
|
+
provider: r.slug.startsWith("ollama/") ? "ollama" : "openrouter",
|
|
124
|
+
dispatches: r.dispatches,
|
|
125
|
+
promptTokens: r.prompt_tokens,
|
|
126
|
+
cachedTokens: r.cached_tokens,
|
|
127
|
+
completionTokens: r.completion_tokens,
|
|
128
|
+
spendUsd: r.spend,
|
|
129
|
+
escalations: r.escalations,
|
|
130
|
+
errors: r.errors,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const EXPORT_COLUMNS = ["day", "harness", "model", "provider", "dispatches", "prompt_tokens", "cached_tokens", "completion_tokens", "spend_usd", "escalations", "errors"] as const;
|
|
135
|
+
|
|
136
|
+
export function csvCell(v: string | number): string {
|
|
137
|
+
const s = String(v);
|
|
138
|
+
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** CSV of export rows; spend to 6 decimals so sub-cent rows survive. */
|
|
142
|
+
export function exportCsv(rows: readonly ExportRow[]): string {
|
|
143
|
+
const lines = [EXPORT_COLUMNS.join(",")];
|
|
144
|
+
for (const r of rows) lines.push([r.day, r.harnessId, r.slug, r.provider, r.dispatches, r.promptTokens, r.cachedTokens, r.completionTokens, r.spendUsd.toFixed(6), r.escalations, r.errors].map(csvCell).join(","));
|
|
145
|
+
return `${lines.join("\n")}\n`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `?harness=a,b` → scope; absent or empty → everything. */
|
|
149
|
+
export function harnessScopeParam(raw: string | null): HarnessScope {
|
|
150
|
+
if (raw === null) return null;
|
|
151
|
+
const ids = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
152
|
+
return ids.length === 0 ? null : ids;
|
|
153
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { parseArgv } from "./cli/args.ts";
|
|
12
12
|
import { configCommand } from "./cli/config-cmd.ts";
|
|
13
13
|
import { explainCommand } from "./cli/explain.ts";
|
|
14
|
+
import { exportCommand } from "./cli/export.ts";
|
|
14
15
|
import { modelsCommand } from "./cli/models.ts";
|
|
15
16
|
import { reportCommand } from "./cli/report.ts";
|
|
16
17
|
import { serveCommand } from "./cli/serve.ts";
|
|
@@ -23,6 +24,7 @@ Usage: auto-model-router <command> [options]
|
|
|
23
24
|
serve Run the router as a standalone process (for non-omp harnesses)
|
|
24
25
|
stats Show routed spend, per-model share, and escalation rates
|
|
25
26
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
27
|
+
export One row per day, harness and model as CSV (--json for rows)
|
|
26
28
|
models Show what each complexity tier would consider, and why
|
|
27
29
|
explain Route a saved request without dispatching it, and explain the decision
|
|
28
30
|
config Interactive wizard over the router's own config.yml
|
|
@@ -73,6 +75,9 @@ async function main(): Promise<number> {
|
|
|
73
75
|
case "report":
|
|
74
76
|
await reportCommand(args);
|
|
75
77
|
return 0;
|
|
78
|
+
case "export":
|
|
79
|
+
await exportCommand(args);
|
|
80
|
+
return 0;
|
|
76
81
|
case "models":
|
|
77
82
|
await modelsCommand(args);
|
|
78
83
|
return 0;
|
package/src/lib.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type { RouterConfig } from "./config/types.ts";
|
|
|
19
19
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
20
20
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
|
21
21
|
export { openDb } from "./util/sqlite.ts";
|
|
22
|
+
export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
|
|
22
23
|
export { createLedger } from "./cost/ledger.ts";
|
|
23
24
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
24
25
|
export type { RequestPolicy } from "./wire/types.ts";
|
package/src/server/http.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { createDigester } from "./digest.ts";
|
|
|
10
10
|
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
|
+
import { exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
|
|
14
|
+
import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
|
|
13
15
|
import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
14
16
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
15
17
|
import { createRouter } from "../router/index.ts";
|
|
@@ -186,6 +188,12 @@ function isLoopbackHostHeader(hostHeader: string | null): boolean {
|
|
|
186
188
|
return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
/** `?days=` bounded to [1, 365], `dflt` when absent or unparsable. */
|
|
192
|
+
function clampDays(raw: string | null, dflt: number): number {
|
|
193
|
+
const n = raw === null ? dflt : Number.parseInt(raw, 10);
|
|
194
|
+
return Number.isInteger(n) ? Math.min(Math.max(n, 1), 365) : dflt;
|
|
195
|
+
}
|
|
196
|
+
|
|
189
197
|
export function startServer(cfg: RouterConfig): StartedServer {
|
|
190
198
|
const log = createLogger(cfg.logLevel);
|
|
191
199
|
|
|
@@ -327,9 +335,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
327
335
|
parse(body: unknown, headers: Headers): NormRequest;
|
|
328
336
|
streaming(model: string): { sink: ResponseSink; response: Response };
|
|
329
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;
|
|
330
340
|
}
|
|
331
341
|
const CHAT_WIRE: Wire = { parse: parseChatRequest, streaming: createStreamingSink, buffered: createBufferedSink };
|
|
332
342
|
const RESPONSES_WIRE: Wire = { parse: parseResponsesRequest, streaming: createResponsesStreamingSink, buffered: createResponsesBufferedSink };
|
|
343
|
+
const MESSAGES_WIRE: Wire = createMessagesWire(cfg.anthropic.models);
|
|
333
344
|
|
|
334
345
|
const handleTurn = async (req: Request, wire: Wire): Promise<Response> => {
|
|
335
346
|
let normReq: NormRequest;
|
|
@@ -340,8 +351,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
340
351
|
// runTurn's `finally`, so it must be released here or every malformed
|
|
341
352
|
// request permanently consumes one of maxConcurrentTurns.
|
|
342
353
|
releaseTurn();
|
|
343
|
-
|
|
344
|
-
return
|
|
354
|
+
const render = wire.error ?? wireErrorResponse;
|
|
355
|
+
if (err instanceof WireErrorException) return render(err.wireError);
|
|
356
|
+
return render({
|
|
345
357
|
status: 400,
|
|
346
358
|
code: "invalid_json",
|
|
347
359
|
message: err instanceof Error ? err.message : "request body is not valid JSON",
|
|
@@ -395,13 +407,15 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
395
407
|
return wireErrorResponse({ status: 403, code: "forbidden", message: "invalid host" });
|
|
396
408
|
}
|
|
397
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;
|
|
398
413
|
if (cfg.server.apiKey !== undefined && cfg.server.apiKey !== "") {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
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" });
|
|
402
417
|
}
|
|
403
418
|
|
|
404
|
-
const url = new URL(req.url);
|
|
405
419
|
try {
|
|
406
420
|
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
407
421
|
if (!acquireTurn()) {
|
|
@@ -416,18 +430,47 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
416
430
|
}
|
|
417
431
|
return await handleTurn(req, RESPONSES_WIRE);
|
|
418
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
|
+
}
|
|
419
447
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
420
448
|
return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
|
|
421
449
|
}
|
|
422
450
|
if (req.method === "GET" && url.pathname === "/v1/router/stats") {
|
|
423
451
|
return json(computeStats(ledger));
|
|
424
452
|
}
|
|
453
|
+
if (req.method === "GET" && url.pathname === "/v1/router/spend") {
|
|
454
|
+
// Spend since an instant over a harness set: what a front door's
|
|
455
|
+
// budget check needs when it cannot read the ledger file.
|
|
456
|
+
const since = Number.parseInt(url.searchParams.get("sinceMs") ?? "", 10);
|
|
457
|
+
if (!Number.isFinite(since)) return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "sinceMs required" });
|
|
458
|
+
return json({ sinceMs: since, usd: spendUsdSince(db, since, harnessScopeParam(url.searchParams.get("harness"))) });
|
|
459
|
+
}
|
|
460
|
+
if (req.method === "GET" && url.pathname === "/v1/router/feedback") {
|
|
461
|
+
const days = clampDays(url.searchParams.get("days"), 30);
|
|
462
|
+
return json({ days, ...feedbackView(db, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness"))) });
|
|
463
|
+
}
|
|
464
|
+
if (req.method === "GET" && url.pathname === "/v1/router/export") {
|
|
465
|
+
const days = clampDays(url.searchParams.get("days"), 30);
|
|
466
|
+
const rows = exportRows(db, Date.now() - days * 86_400_000, harnessScopeParam(url.searchParams.get("harness")));
|
|
467
|
+
if (url.searchParams.get("format") === "json") return json({ days, rows });
|
|
468
|
+
return new Response(exportCsv(rows), { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="auto-model-router-export-${new Date().toISOString().slice(0, 10)}.csv"` } });
|
|
469
|
+
}
|
|
425
470
|
if (req.method === "GET" && url.pathname === "/v1/router/report") {
|
|
426
471
|
// Usage analytics for `/router report` and the CLI: bounded window,
|
|
427
472
|
// optional harness scope (the X-Omp-Harness header value).
|
|
428
|
-
const
|
|
429
|
-
const parsedDays = rawDays === null ? 7 : Number.parseInt(rawDays, 10);
|
|
430
|
-
const windowDays = Number.isInteger(parsedDays) ? Math.min(Math.max(parsedDays, 1), 365) : 7;
|
|
473
|
+
const windowDays = clampDays(url.searchParams.get("days"), 7);
|
|
431
474
|
const harnessId = url.searchParams.get("harness") ?? "";
|
|
432
475
|
const report = buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) });
|
|
433
476
|
// ?format=text: the rendered report for harnesses without a renderer of their own (the Hermes plugin).
|