auto-model-router 0.31.0 → 0.33.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.
Files changed (116) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +61 -2
  3. package/omp-extension/router-configure.ts +9 -7
  4. package/package.json +1 -1
  5. package/src/cli/config-cmd.ts +8 -7
  6. package/src/cli/explain.ts +10 -5
  7. package/src/cli/export.ts +6 -5
  8. package/src/cli/models.ts +10 -7
  9. package/src/cli/report.ts +6 -1
  10. package/src/cli/stats.ts +7 -7
  11. package/src/config/load.ts +10 -1
  12. package/src/config/types.ts +10 -1
  13. package/src/context/bridge.ts +7 -7
  14. package/src/context/index.ts +3 -3
  15. package/src/context/store.ts +39 -56
  16. package/src/context/types.ts +7 -6
  17. package/src/cost/blended.ts +28 -7
  18. package/src/cost/feedback.ts +33 -37
  19. package/src/cost/ledger-sql.ts +547 -0
  20. package/src/cost/ledger.ts +30 -459
  21. package/src/cost/report.ts +171 -129
  22. package/src/cost/retention.ts +10 -10
  23. package/src/cost/summary.ts +15 -10
  24. package/src/cost/types.ts +43 -62
  25. package/src/cost/views.ts +79 -49
  26. package/src/lib.ts +6 -2
  27. package/src/router/candidates.ts +16 -15
  28. package/src/router/classify.ts +6 -4
  29. package/src/router/index.ts +95 -9
  30. package/src/router/select.ts +38 -21
  31. package/src/router/state.ts +90 -102
  32. package/src/router/types.ts +13 -5
  33. package/src/server/advise.ts +6 -4
  34. package/src/server/compaction-digest.ts +1 -1
  35. package/src/server/digest.ts +9 -10
  36. package/src/server/http.ts +107 -41
  37. package/src/server/providers.ts +18 -4
  38. package/src/server/turn.ts +35 -10
  39. package/src/tokens/estimate.ts +16 -6
  40. package/src/upstream/anthropic.ts +5 -4
  41. package/src/upstream/compat.ts +13 -7
  42. package/src/upstream/ollama-usage.ts +21 -11
  43. package/src/upstream/ollama.ts +4 -3
  44. package/src/upstream/openrouter.ts +4 -3
  45. package/src/upstream/types.ts +7 -0
  46. package/src/util/schema.ts +201 -0
  47. package/src/util/sql.ts +246 -0
  48. package/src/wire/anthropic/messages.ts +3 -4
  49. package/src/wire/openai/request.ts +30 -0
  50. package/src/wire/types.ts +22 -0
  51. package/test/anthropic-wire.test.ts +9 -9
  52. package/test/benchmark-feeds.test.ts +7 -7
  53. package/test/cache-control.test.ts +7 -7
  54. package/test/cache-estimate.test.ts +5 -5
  55. package/test/catalog-view.test.ts +4 -4
  56. package/test/catalog.test.ts +11 -11
  57. package/test/classify.test.ts +24 -24
  58. package/test/compaction.test.ts +20 -20
  59. package/test/config-wizard.test.ts +32 -32
  60. package/test/config.test.ts +10 -10
  61. package/test/connect-harnesses.test.ts +11 -11
  62. package/test/context-bridge.test.ts +40 -30
  63. package/test/context-prune.test.ts +43 -36
  64. package/test/context-query.test.ts +8 -8
  65. package/test/controls.test.ts +54 -27
  66. package/test/cost.test.ts +12 -12
  67. package/test/digest.test.ts +55 -44
  68. package/test/embed-lifecycle.test.ts +5 -5
  69. package/test/embed-logic.test.ts +26 -26
  70. package/test/escalate.test.ts +17 -17
  71. package/test/eval.test.ts +13 -13
  72. package/test/executable.test.ts +6 -6
  73. package/test/exploration.test.ts +19 -20
  74. package/test/failover.test.ts +63 -21
  75. package/test/fakes.ts +105 -0
  76. package/test/features.test.ts +21 -21
  77. package/test/harness-requests.test.ts +3 -3
  78. package/test/harness-switch.test.ts +5 -5
  79. package/test/hold-exploration.test.ts +13 -13
  80. package/test/hot-reload.test.ts +5 -5
  81. package/test/learned.test.ts +5 -5
  82. package/test/ledger-sql.test.ts +342 -0
  83. package/test/mcp-entry.test.ts +5 -5
  84. package/test/migrations.test.ts +28 -22
  85. package/test/models-yml.test.ts +18 -18
  86. package/test/ollama.test.ts +40 -34
  87. package/test/omp-credentials.test.ts +16 -16
  88. package/test/policy.test.ts +3 -3
  89. package/test/reconfigure.test.ts +4 -4
  90. package/test/redaction.test.ts +41 -35
  91. package/test/remote.test.ts +12 -12
  92. package/test/report-logic.test.ts +8 -8
  93. package/test/report.test.ts +95 -87
  94. package/test/retention.test.ts +79 -66
  95. package/test/schema.test.ts +123 -0
  96. package/test/scope.test.ts +8 -8
  97. package/test/select.test.ts +216 -257
  98. package/test/skills.test.ts +3 -3
  99. package/test/sql-shim.test.ts +154 -0
  100. package/test/state.test.ts +43 -36
  101. package/test/summary.test.ts +38 -27
  102. package/test/tier-plan.test.ts +45 -62
  103. package/test/toast-logic.test.ts +31 -31
  104. package/test/tokens.test.ts +95 -80
  105. package/test/trust-attribution.test.ts +217 -187
  106. package/test/trust-window.test.ts +37 -32
  107. package/test/turn.test.ts +55 -23
  108. package/test/upstream-keys.test.ts +240 -0
  109. package/test/upstreams.test.ts +13 -13
  110. package/test/views.test.ts +81 -59
  111. package/test/wire-request.test.ts +17 -17
  112. package/test/wire-responses.test.ts +4 -4
  113. package/tools/agentdox-e2e.ts +5 -2
  114. package/tools/export-benchmarks.ts +5 -5
  115. package/tools/ledger-parity.ts +266 -0
  116. package/tools/replay.ts +16 -8
@@ -24,7 +24,7 @@
24
24
  * percents) rather than 100%, which errs toward keeping the bias on.
25
25
  */
26
26
 
27
- import type { Database } from "bun:sqlite";
27
+ import { num, type SqlDb } from "../util/sql.ts";
28
28
  import type { Logger } from "../util/log.ts";
29
29
 
30
30
  export interface OllamaUsage {
@@ -158,9 +158,9 @@ export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () =>
158
158
 
159
159
  /** Where calibration samples come from and go: the ledger's Ollama total and the plan's credits. */
160
160
  export interface CalibrationDeps {
161
- db: Database;
161
+ db: SqlDb;
162
162
  /** The ledger's all-time Ollama spend, read at sampling time. */
163
- ledgerUsd(): number;
163
+ ledgerUsd(): Promise<number>;
164
164
  /** Configured credit override (0 = detect from the plan). */
165
165
  planCreditsOverrideUsd: number;
166
166
  }
@@ -172,17 +172,25 @@ export function createOllamaUsageSource(
172
172
  // the dashboard later, so the reader stays live and simply idles until it is.
173
173
  if (opts.pollMs <= 0) return NO_USAGE;
174
174
  const cal = opts.calibration;
175
- const insertSample = cal === undefined ? null : cal.db.query("INSERT OR REPLACE INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd) VALUES (?, ?, ?)");
176
- const readSamples = cal === undefined ? null : cal.db.query("SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= ? ORDER BY at_ms ASC");
177
175
  let calibrationMemo: OllamaCalibration | null = null;
178
- function sample(usage: OllamaUsage): void {
179
- if (cal === null || cal === undefined || insertSample === null || readSamples === null) return;
176
+ async function sample(usage: OllamaUsage): Promise<void> {
177
+ if (cal === null || cal === undefined) return;
180
178
  const credits = ollamaPlanCredits(usage, cal.planCreditsOverrideUsd);
181
179
  if (credits === null || usage.monthlyUsedFraction === null) return;
182
180
  try {
183
- insertSample.run(usage.fetchedAtMs, usage.monthlyUsedFraction * credits, cal.ledgerUsd());
184
- const rows = readSamples.all(usage.fetchedAtMs - 30 * 86_400_000) as { at_ms: number; meter_usd: number; ledger_usd: number }[];
185
- calibrationMemo = calibrationFrom(rows.map((r) => ({ atMs: r.at_ms, meterUsd: r.meter_usd, ledgerUsd: r.ledger_usd })));
181
+ const db = cal.db;
182
+ // `INSERT OR REPLACE` is SQLite-only; `ON CONFLICT` says the same thing
183
+ // on both engines, and the key is the sample instant.
184
+ await db.sql`INSERT INTO ollama_meter_samples (at_ms, meter_usd, ledger_usd)
185
+ VALUES (${usage.fetchedAtMs}, ${usage.monthlyUsedFraction * credits}, ${await cal.ledgerUsd()})
186
+ ON CONFLICT (at_ms) DO UPDATE SET meter_usd = excluded.meter_usd, ledger_usd = excluded.ledger_usd`;
187
+ const rows = await db.query<{ at_ms: unknown; meter_usd: unknown; ledger_usd: unknown }>(
188
+ "SELECT at_ms, meter_usd, ledger_usd FROM ollama_meter_samples WHERE at_ms >= $since ORDER BY at_ms ASC",
189
+ { since: usage.fetchedAtMs - 30 * 86_400_000 },
190
+ );
191
+ calibrationMemo = calibrationFrom(
192
+ rows.map((r) => ({ atMs: num(r.at_ms), meterUsd: num(r.meter_usd), ledgerUsd: num(r.ledger_usd) })),
193
+ );
186
194
  } catch (err) {
187
195
  opts.log.debug("ollama calibration sample failed", { error: err instanceof Error ? err.message : String(err) });
188
196
  }
@@ -232,7 +240,9 @@ export function createOllamaUsageSource(
232
240
  if (parsed !== null) {
233
241
  current = { ...parsed, plan };
234
242
  warned = false;
235
- sample(current);
243
+ // Awaited: the next poll's calibration reads what this one wrote,
244
+ // and a shared store makes that a round trip rather than a call.
245
+ await sample(current);
236
246
  } else if (!warned) {
237
247
  warned = true;
238
248
  opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");
@@ -146,9 +146,9 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
146
146
  log.warn("ollama cloud unavailable; routing around it", { kind: err.kind, cooldownMs: ms, message: err.message });
147
147
  };
148
148
 
149
- function headers(extra: Record<string, string> = {}): Record<string, string> {
149
+ function headers(extra: Record<string, string> = {}, apiKey: string = o.apiKey): Record<string, string> {
150
150
  const h: Record<string, string> = { "content-type": "application/json", ...extra };
151
- if (o.apiKey !== "") h.authorization = `Bearer ${o.apiKey}`;
151
+ if (apiKey !== "") h.authorization = `Bearer ${apiKey}`;
152
152
  return h;
153
153
  }
154
154
 
@@ -195,7 +195,8 @@ export function createOllamaClient(cfg: RouterConfig, fetchImpl: FetchLike = fet
195
195
  try {
196
196
  res = await fetchImpl(`${baseUrl()}/chat/completions`, {
197
197
  method: "POST",
198
- headers: headers(),
198
+ // A per-turn credential wins for this dispatch only; cfg is never written to.
199
+ headers: headers({}, opts.upstreamKeys?.ollama ?? o.apiKey),
199
200
  body: JSON.stringify(body),
200
201
  signal: composeSignal(opts.signal),
201
202
  });
@@ -114,14 +114,14 @@ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
114
114
  return caller ?? timeout;
115
115
  }
116
116
 
117
- function headers(extra: Record<string, string>): Record<string, string> {
117
+ function headers(extra: Record<string, string>, apiKey: string = cfg.openrouter.apiKey): Record<string, string> {
118
118
  const h: Record<string, string> = {
119
119
  "content-type": "application/json",
120
120
  "x-title": cfg.openrouter.title,
121
121
  ...extra,
122
122
  };
123
123
  // /models is public; an empty key must not produce a broken Bearer header.
124
- if (cfg.openrouter.apiKey !== "") h.authorization = `Bearer ${cfg.openrouter.apiKey}`;
124
+ if (apiKey !== "") h.authorization = `Bearer ${apiKey}`;
125
125
  if (cfg.openrouter.referer) h["http-referer"] = cfg.openrouter.referer;
126
126
  return h;
127
127
  }
@@ -139,7 +139,8 @@ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
139
139
  try {
140
140
  res = await fetch(`${baseUrl}/chat/completions`, {
141
141
  method: "POST",
142
- headers: headers({ "x-session-id": opts.sessionId }),
142
+ // A per-turn credential wins for this dispatch only; cfg is never written to.
143
+ headers: headers({ "x-session-id": opts.sessionId }, opts.upstreamKeys?.openrouter ?? cfg.openrouter.apiKey),
143
144
  body: JSON.stringify(body),
144
145
  signal,
145
146
  });
@@ -43,6 +43,13 @@ export interface DispatchOptions {
43
43
  body: Record<string, unknown>;
44
44
  /** Forwarded as the `x-session-id` header, mirroring body `session_id`. */
45
45
  sessionId: string;
46
+ /**
47
+ * Per-turn credentials by upstream id (`NormRequest.upstreamKeys`). The
48
+ * client dispatching this body prefers its own entry over the configured
49
+ * `apiKey`, without ever writing to the shared config: concurrent turns
50
+ * carry different tenants' keys over the same `UpstreamEntry`.
51
+ */
52
+ upstreamKeys?: Readonly<Record<string, string>>;
46
53
  signal: AbortSignal;
47
54
  }
48
55
 
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The store's schema, for either engine.
3
+ *
4
+ * `util/sqlite.ts` remains the migration path for a SQLite FILE that already
5
+ * exists: nineteen versions have shipped, and an old file still needs its
6
+ * `ALTER TABLE`s applied in order. This module declares the FINAL shape of
7
+ * every table instead, which is what a fresh store needs — a Postgres database
8
+ * has no history to migrate, and on an already-migrated file every statement
9
+ * here is a no-op.
10
+ *
11
+ * The two must agree, so the definitions below are transcribed from
12
+ * `util/sqlite.ts` with its incremental columns folded in, and
13
+ * `test/schema.test.ts` compares the two engine-by-engine rather than trusting
14
+ * that they were copied correctly.
15
+ */
16
+
17
+ import type { SqlDb } from "./sql.ts";
18
+
19
+ /**
20
+ * Creates every table and index the router uses. Idempotent, so boot order
21
+ * never matters — the property the SQLite bootstrap has always had.
22
+ */
23
+ export async function migrateStore(db: SqlDb): Promise<void> {
24
+ const json = db.type("json");
25
+ const float = db.type("float");
26
+ const big = db.type("bigint");
27
+ // A single-row cache keyed on a constant: `CHECK (id = 1)` is portable, and
28
+ // it is what keeps a second payload from ever accumulating.
29
+ const singleton = (name: string, extra = ""): string =>
30
+ `CREATE TABLE IF NOT EXISTS ${name} (
31
+ id INTEGER PRIMARY KEY CHECK (id = 1),
32
+ payload ${json} NOT NULL,
33
+ fetched_at_ms ${big} NOT NULL${extra}
34
+ )`;
35
+
36
+ const statements: string[] = [
37
+ singleton("catalog_cache", `,\n\t\t\tetag TEXT,\n\t\t\tkey_scoped INTEGER NOT NULL DEFAULT 0`),
38
+ singleton("ollama_catalog_cache"),
39
+ singleton("benchmark_cache"),
40
+ singleton("local_scores"),
41
+
42
+ // One row per dispatched upstream generation. The columns nineteen
43
+ // migrations added are declared here as they finally stand.
44
+ `CREATE TABLE IF NOT EXISTS ledger (
45
+ id TEXT PRIMARY KEY,
46
+ created_at_ms ${big} NOT NULL,
47
+ conversation_key TEXT NOT NULL,
48
+ session_id TEXT NOT NULL,
49
+ turn INTEGER NOT NULL,
50
+ requested_model TEXT NOT NULL,
51
+ harness_id TEXT NOT NULL DEFAULT '',
52
+ omp_session_id TEXT NOT NULL DEFAULT '',
53
+ slug TEXT NOT NULL,
54
+ served_slug TEXT,
55
+ tier TEXT NOT NULL,
56
+ classification_source TEXT NOT NULL,
57
+ reasons ${json} NOT NULL,
58
+ predicted_usd ${float} NOT NULL,
59
+ reported_usd ${float},
60
+ usage ${json} NOT NULL,
61
+ cost_breakdown ${json},
62
+ attempt INTEGER NOT NULL,
63
+ escalation_signal TEXT,
64
+ latency_ms INTEGER NOT NULL,
65
+ ttft_ms INTEGER,
66
+ finish_reason TEXT,
67
+ wasted INTEGER NOT NULL DEFAULT 0,
68
+ upstream_generation_id TEXT,
69
+ error TEXT,
70
+ error_kind TEXT,
71
+ features ${json},
72
+ score ${float},
73
+ confidence ${float},
74
+ task TEXT,
75
+ classifier_reasons ${json},
76
+ explored_from TEXT,
77
+ hold_arm INTEGER,
78
+ prompt_tokens_saved INTEGER,
79
+ scope TEXT,
80
+ redactions INTEGER
81
+ )`,
82
+ "CREATE INDEX IF NOT EXISTS idx_ledger_conversation ON ledger (conversation_key)",
83
+ "CREATE INDEX IF NOT EXISTS idx_ledger_created ON ledger (created_at_ms)",
84
+ "CREATE INDEX IF NOT EXISTS idx_ledger_slug ON ledger (slug)",
85
+ // Per-slug newest-first reads (the latency window, any windowed trust).
86
+ // Without this they sorted every row the slug ever had: measured on a
87
+ // real ledger, 5-10ms and RISING with history against a flat 0.04-0.08ms.
88
+ "CREATE INDEX IF NOT EXISTS idx_ledger_slug_created ON ledger (slug, created_at_ms DESC)",
89
+ "CREATE INDEX IF NOT EXISTS idx_ledger_harness_created ON ledger (harness_id, created_at_ms DESC)",
90
+ "CREATE INDEX IF NOT EXISTS idx_ledger_slug_harness_created ON ledger (slug, harness_id, created_at_ms DESC)",
91
+ "CREATE INDEX IF NOT EXISTS idx_ledger_session ON ledger (omp_session_id, created_at_ms DESC)",
92
+
93
+ `CREATE TABLE IF NOT EXISTS token_calibration (
94
+ tokenizer TEXT PRIMARY KEY,
95
+ est_bytes ${big} NOT NULL,
96
+ actual_tokens ${big} NOT NULL,
97
+ samples ${big} NOT NULL
98
+ )`,
99
+
100
+ // Per-conversation routing memory: the sticky window, which model's
101
+ // prompt cache is warm, and the accumulated spend the budget guard reads.
102
+ `CREATE TABLE IF NOT EXISTS conversations (
103
+ key TEXT PRIMARY KEY,
104
+ session_id TEXT NOT NULL,
105
+ turn INTEGER NOT NULL DEFAULT 0,
106
+ current_slug TEXT,
107
+ current_tier TEXT,
108
+ sticky_until_turn INTEGER NOT NULL DEFAULT 0,
109
+ escalations INTEGER NOT NULL DEFAULT 0,
110
+ spent_usd ${float} NOT NULL DEFAULT 0,
111
+ last_prompt_tokens INTEGER NOT NULL DEFAULT 0,
112
+ cache_warm_slug TEXT,
113
+ cache_warm_at_ms ${big} NOT NULL DEFAULT 0,
114
+ context_version TEXT,
115
+ context_fetched_at_ms ${big} NOT NULL DEFAULT 0,
116
+ compaction_plan ${json},
117
+ compaction_plan_tokens INTEGER NOT NULL DEFAULT 0,
118
+ upgrade_deferred_tier TEXT,
119
+ updated_at_ms ${big} NOT NULL DEFAULT 0
120
+ )`,
121
+
122
+ // agentdox bridge. Blocks are content-addressed so many conversations on
123
+ // one project share a copy, and so a restart can re-inject the SAME bytes
124
+ // a conversation was already using (the upstream cache outlives us).
125
+ `CREATE TABLE IF NOT EXISTS context_blocks (
126
+ version TEXT PRIMARY KEY,
127
+ scope TEXT NOT NULL,
128
+ block TEXT NOT NULL,
129
+ fetched_at_ms ${big} NOT NULL
130
+ )`,
131
+
132
+ // Plan-meter readings beside the ledger's own Ollama total at the same
133
+ // instant, so the estimate can be calibrated against the bill.
134
+ `CREATE TABLE IF NOT EXISTS ollama_meter_samples (
135
+ at_ms ${big} PRIMARY KEY,
136
+ meter_usd ${float} NOT NULL,
137
+ ledger_usd ${float} NOT NULL
138
+ )`,
139
+
140
+ // User verdicts on routed turns, tied to the ledger row judged.
141
+ `CREATE TABLE IF NOT EXISTS feedback (
142
+ id TEXT PRIMARY KEY,
143
+ ledger_id TEXT NOT NULL,
144
+ omp_session_id TEXT NOT NULL DEFAULT '',
145
+ slug TEXT NOT NULL,
146
+ tier TEXT NOT NULL,
147
+ verdict TEXT NOT NULL,
148
+ note TEXT NOT NULL DEFAULT '',
149
+ created_at_ms ${big} NOT NULL
150
+ )`,
151
+ "CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback (created_at_ms)",
152
+ "CREATE INDEX IF NOT EXISTS idx_feedback_ledger ON feedback (ledger_id)",
153
+
154
+ // Small durable markers (when the daily summary was last posted, per harness).
155
+ `CREATE TABLE IF NOT EXISTS router_kv (
156
+ key TEXT PRIMARY KEY,
157
+ value TEXT NOT NULL,
158
+ updated_at_ms ${big} NOT NULL
159
+ )`,
160
+
161
+ `CREATE TABLE IF NOT EXISTS agentdox_sessions (
162
+ conversation_key TEXT PRIMARY KEY,
163
+ scope TEXT NOT NULL,
164
+ session_id TEXT NOT NULL,
165
+ created_at_ms ${big} NOT NULL
166
+ )`,
167
+ ];
168
+
169
+ // `IF NOT EXISTS` is not atomic on Postgres: two replicas booting against a
170
+ // fresh database both pass the existence check and the loser fails on the
171
+ // unique index over pg_type (23505), or on the table name itself (42P07 /
172
+ // 42710). Measured: one of two replicas started together died with
173
+ // "duplicate key value violates unique constraint pg_type_typname_nsp_index".
174
+ // The condition those errors report is the condition the statement asked to
175
+ // tolerate, so they are the success case arriving from the other replica.
176
+ const RACED = new Set(["23505", "42P07", "42710"]);
177
+ for (const statement of statements) {
178
+ try {
179
+ await db.sql.unsafe(statement);
180
+ } catch (err) {
181
+ const code = (err as { errno?: unknown }).errno;
182
+ if (!RACED.has(String(code))) throw err;
183
+ }
184
+ }
185
+ }
186
+
187
+ /** Every table `migrateStore` creates, for tests and for teardown. */
188
+ export const STORE_TABLES = [
189
+ "catalog_cache",
190
+ "ollama_catalog_cache",
191
+ "benchmark_cache",
192
+ "local_scores",
193
+ "ledger",
194
+ "token_calibration",
195
+ "conversations",
196
+ "context_blocks",
197
+ "ollama_meter_samples",
198
+ "feedback",
199
+ "router_kv",
200
+ "agentdox_sessions",
201
+ ] as const;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * One SQL handle for both engines.
3
+ *
4
+ * The ledger has to live in the same database as the rest of a deployment's
5
+ * state when that state is shared (spend read from a file that lags the
6
+ * database it must agree with is a cap that silently over-admits), and in a
7
+ * local file when it is not. `Bun.SQL` speaks both, so the ledger is written
8
+ * once against this shim rather than twice.
9
+ *
10
+ * Only three things actually differ, and they are the three things here:
11
+ *
12
+ * 1. **JSON access.** `json_extract(usage, '$.promptTokens')` against
13
+ * `(usage->>'promptTokens')::numeric`.
14
+ * 2. **Numeric results.** Postgres returns `COUNT(*)` and `SUM(BIGINT)` as
15
+ * STRINGS. Arithmetic on those silently produces wrong answers rather than
16
+ * throwing — a measured trust score came out 0.9756 instead of 0.9726 this
17
+ * way — so every numeric read goes through `num`.
18
+ * 3. **Column types.** `JSONB`/`DOUBLE PRECISION` against `TEXT`/`REAL`.
19
+ *
20
+ * Everything else — parameters, `IN ${sql(array)}`, window functions,
21
+ * `ON CONFLICT`, `DELETE ... RETURNING`, bulk insert, transactions — is
22
+ * identical on both, verified by `tools/dialect-probe.ts`.
23
+ */
24
+
25
+ import { SQL } from "bun";
26
+
27
+ export type Dialect = "sqlite" | "postgres";
28
+
29
+ export interface SqlDb {
30
+ readonly sql: SQL;
31
+ readonly dialect: Dialect;
32
+ /** A JSON member as a NUMBER-typed SQL expression, for aggregates and comparisons. */
33
+ jsonNum(column: string, key: string): string;
34
+ /** A JSON member as TEXT, for equality against string values. */
35
+ jsonText(column: string, key: string): string;
36
+ /**
37
+ * A NESTED JSON member as a number, e.g. `features.anatomy.messages`.
38
+ *
39
+ * `jsonNum` cannot express this: SQLite takes a whole path in one string
40
+ * (`'$.anatomy.messages'`), while Postgres' `->>` reads a SINGLE key, so a
41
+ * dotted key silently reads NULL there — a whole report section came back
42
+ * null rather than failing.
43
+ */
44
+ jsonPathNum(column: string, path: readonly string[]): string;
45
+ /**
46
+ * A JSON member that holds a BOOLEAN, as 1/0.
47
+ *
48
+ * The engines disagree twice over: SQLite's `json_extract` yields the
49
+ * INTEGER 1 for JSON `true`, Postgres' `->>` yields the TEXT 'true', and
50
+ * SQLite's `IN` does not coerce between them. Comparing the wrong way round
51
+ * silently misclassifies every row — it counted router-ESTIMATED cache hits
52
+ * as measured ones and inflated cache reliability samples by 2%.
53
+ */
54
+ jsonBool(column: string, key: string): string;
55
+ /**
56
+ * Substring test as a boolean expression. SQLite has `instr(haystack,
57
+ * needle) > 0`; Postgres spells it `position(needle in haystack) > 0`.
58
+ */
59
+ contains(haystack: string, needle: string): string;
60
+ /**
61
+ * An epoch-millisecond column as a `YYYY-MM-DD` UTC day. SQLite has
62
+ * `strftime(..., 'unixepoch')`; Postgres needs `to_timestamp` plus an
63
+ * explicit UTC conversion, or the server's timezone silently decides which
64
+ * day a turn was billed on.
65
+ */
66
+ utcDay(msColumn: string): string;
67
+ /** Whether a table exists, without reading engine-specific catalog tables. */
68
+ tableExists(name: string): Promise<boolean>;
69
+ /**
70
+ * Runs SQL written with `$name` placeholders, whichever way the engine
71
+ * wants them numbered.
72
+ *
73
+ * The reporting queries are assembled from optional filters (a harness
74
+ * scope, an upper time bound), which a tagged template cannot express — the
75
+ * shape of the statement is decided at runtime. Rewriting them as string
76
+ * concatenation with positional parameters would renumber every bind by
77
+ * hand, which is how a filter ends up reading the wrong column.
78
+ */
79
+ query<T>(text: string, binds?: Record<string, unknown>): Promise<T[]>;
80
+ /** `query`, for a statement that yields at most one row. */
81
+ one<T>(text: string, binds?: Record<string, unknown>): Promise<T | null>;
82
+ /** A column type that differs between engines. */
83
+ type(kind: "json" | "float" | "bigint"): string;
84
+ /**
85
+ * Cast suffix for a parameter that may be NULL. Postgres refuses to infer a
86
+ * type for a bare NULL placeholder ("could not determine data type of
87
+ * parameter $2"), which the `(${x} IS NULL OR col = ${x})` idiom for an
88
+ * optional filter relies on; SQLite has no cast syntax to add. Empty there.
89
+ */
90
+ readonly nullableText: string;
91
+ /**
92
+ * Two-argument scalar minimum. SQLite spells it `MIN(a, b)`; Postgres
93
+ * reserves `MIN` for the aggregate and needs `LEAST(a, b)`.
94
+ */
95
+ least(a: string, b: string): string;
96
+ close(): Promise<void>;
97
+ }
98
+
99
+ /**
100
+ * `sqlite://` (or a bare path) and `postgres://`/`postgresql://` URLs. A bare
101
+ * path is accepted because the router's config has always taken
102
+ * `ledger.path`, and a deployment that never opts into Postgres should not
103
+ * have to learn a URL scheme.
104
+ */
105
+ export function dialectOf(url: string): Dialect {
106
+ return url.startsWith("postgres://") || url.startsWith("postgresql://") ? "postgres" : "sqlite";
107
+ }
108
+
109
+ export function openSqlDb(url: string): SqlDb {
110
+ const dialect = dialectOf(url);
111
+ const target = dialect === "sqlite" && !url.startsWith("sqlite:") ? `sqlite://${url}` : url;
112
+ const sql = new SQL(target);
113
+ const pg = dialect === "postgres";
114
+ return {
115
+ sql,
116
+ dialect,
117
+ jsonNum: (column, key) => (pg ? `(${column}->>'${key}')::numeric` : `json_extract(${column}, '$.${key}')`),
118
+ jsonText: (column, key) => (pg ? `(${column}->>'${key}')` : `json_extract(${column}, '$.${key}')`),
119
+ jsonPathNum: (column, path) =>
120
+ pg
121
+ ? `(${column}#>>'{${path.join(",")}}')::numeric`
122
+ : `json_extract(${column}, '$.${path.join(".")}')`,
123
+ jsonBool: (column, key) =>
124
+ pg
125
+ ? `CASE WHEN (${column}->>'${key}') IN ('true', '1') THEN 1 ELSE 0 END`
126
+ : `CASE WHEN json_extract(${column}, '$.${key}') IN (1, 'true', '1') THEN 1 ELSE 0 END`,
127
+ type: (kind) => {
128
+ if (kind === "json") return pg ? "JSONB" : "TEXT";
129
+ if (kind === "float") return pg ? "DOUBLE PRECISION" : "REAL";
130
+ return pg ? "BIGINT" : "INTEGER";
131
+ },
132
+ nullableText: pg ? "::text" : "",
133
+ least: (a, b) => (pg ? `LEAST(${a}, ${b})` : `MIN(${a}, ${b})`),
134
+ // The haystack is cast to text: a JSON column is `jsonb` on Postgres and
135
+ // `position()` refuses it, while sqlite stores the same column as TEXT.
136
+ contains: (haystack, needle) => (pg ? `position(${needle} in (${haystack})::text) > 0` : `instr(${haystack}, ${needle}) > 0`),
137
+ utcDay: (msColumn) =>
138
+ pg
139
+ ? `to_char(to_timestamp(${msColumn} / 1000) AT TIME ZONE 'UTC', 'YYYY-MM-DD')`
140
+ : `strftime('%Y-%m-%d', ${msColumn} / 1000, 'unixepoch')`,
141
+ tableExists: async (name) => {
142
+ // Cheaper and more portable than either catalog table: ask for nothing
143
+ // from it and see whether the statement plans.
144
+ try {
145
+ await sql.unsafe(`SELECT 1 FROM ${name} WHERE 1 = 0`);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
150
+ },
151
+ query: async <T>(text: string, binds: Record<string, unknown> = {}): Promise<T[]> => {
152
+ const { text: prepared, values } = bindNamed(text, binds, pg);
153
+ return (await sql.unsafe(prepared, values)) as T[];
154
+ },
155
+ one: async <T>(text: string, binds: Record<string, unknown> = {}): Promise<T | null> => {
156
+ const { text: prepared, values } = bindNamed(text, binds, pg);
157
+ const rows = (await sql.unsafe(prepared, values)) as T[];
158
+ return rows[0] ?? null;
159
+ },
160
+ close: async () => {
161
+ await sql.end();
162
+ },
163
+ };
164
+ }
165
+
166
+
167
+ /**
168
+ * Rewrites `$name` placeholders to the engine's positional form, in order of
169
+ * first appearance, and returns the matching value array.
170
+ *
171
+ * A name may repeat — `created_at_ms >= $since` and a `CASE` on `$since` in
172
+ * the same statement is normal — and repeats must reuse one parameter slot on
173
+ * Postgres. JSON paths (`'$.isSubagent'`) are not placeholders: the pattern
174
+ * requires a letter or underscore after the `$`, which `$.` fails.
175
+ */
176
+ export function bindNamed(text: string, binds: Record<string, unknown>, pg: boolean): { text: string; values: unknown[] } {
177
+ // Callers that were written against bun:sqlite pass their binds keyed WITH
178
+ // the sigil (`{ $since: 0 }`), which is how every reporting query in this
179
+ // repo already builds them; both forms resolve.
180
+ const valueOf = (name: string): unknown => {
181
+ if (name in binds) return binds[name];
182
+ const sigil = `$${name}`;
183
+ if (sigil in binds) return binds[sigil];
184
+ throw new Error(`sql: no value bound for $${name}`);
185
+ };
186
+ const order: string[] = [];
187
+ const prepared = text.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, name: string) => {
188
+ valueOf(name);
189
+ let index = order.indexOf(name);
190
+ if (index === -1) {
191
+ order.push(name);
192
+ index = order.length - 1;
193
+ }
194
+ return pg ? `$${index + 1}` : "?";
195
+ });
196
+ if (!pg) {
197
+ // SQLite's positional `?` cannot reuse a slot, so a repeated name is
198
+ // bound once per occurrence rather than once per name.
199
+ const values: unknown[] = [];
200
+ text.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, name: string) => {
201
+ values.push(valueOf(name));
202
+ return "";
203
+ });
204
+ return { text: prepared, values };
205
+ }
206
+ return { text: prepared, values: order.map(valueOf) };
207
+ }
208
+
209
+ /**
210
+ * Coerces a value Postgres may have returned as a string. Applied at every
211
+ * numeric read: `Number(null)` is 0, which is wrong for a nullable aggregate,
212
+ * so null and undefined are preserved.
213
+ */
214
+ export function num(value: unknown): number {
215
+ return typeof value === "number" ? value : Number(value ?? 0);
216
+ }
217
+
218
+ /** `num`, but a missing value stays missing rather than becoming 0. */
219
+ export function numOrNull(value: unknown): number | null {
220
+ if (value === null || value === undefined) return null;
221
+ return typeof value === "number" ? value : Number(value);
222
+ }
223
+
224
+ /**
225
+ * JSON for a `json` column. Postgres' driver encodes a JS string destined for
226
+ * `JSONB` as a JSON *string* — `jsonb_typeof` reads `'string'` and every
227
+ * `->>` on it returns NULL — so an object must be passed through unstringified
228
+ * there, while SQLite's TEXT column needs the serialised form.
229
+ */
230
+ export function jsonParam(db: SqlDb, value: unknown): unknown {
231
+ if (value === null || value === undefined) return null;
232
+ return db.dialect === "postgres" ? value : JSON.stringify(value);
233
+ }
234
+
235
+ /** Reads a `json` column back, whichever way it was stored. */
236
+ export function jsonValue<T>(value: unknown): T | null {
237
+ if (value === null || value === undefined) return null;
238
+ if (typeof value === "string") {
239
+ try {
240
+ return JSON.parse(value) as T;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+ return value as T;
246
+ }
@@ -25,8 +25,7 @@
25
25
  */
26
26
 
27
27
  import { encoder, sseDataFrame } from "../../util/sse.ts";
28
- import type { Ledger } from "../../cost/types.ts";
29
- import { estimateTokens } from "../../tokens/estimate.ts";
28
+ import { estimateTokens, type TokenRatio } from "../../tokens/estimate.ts";
30
29
  import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk, WireError } from "../types.ts";
31
30
  import { invalidRequest, WireErrorException } from "../openai/errors.ts";
32
31
  import { parseChatRequest } from "../openai/request.ts";
@@ -217,9 +216,9 @@ export function parseMessagesRequest(body: unknown, headers: Headers, models: Re
217
216
  }
218
217
 
219
218
  /** `POST /v1/messages/count_tokens`: the router's own estimate over the prompt bytes. */
220
- export function countAnthropicTokens(body: unknown, models: Record<string, string>, ledger: Ledger | null): number {
219
+ export function countAnthropicTokens(body: unknown, models: Record<string, string>, ratio: TokenRatio): number {
221
220
  const norm = parseChatRequest(messagesToChatBody({ ...(isRec(body) ? body : {}), stream: false }, models), new Headers());
222
- return estimateTokens(norm.promptBytes, "anthropic", ledger);
221
+ return estimateTokens(norm.promptBytes, "anthropic", ratio);
223
222
  }
224
223
 
225
224
  // ---------------------------------------------------------------------------
@@ -315,6 +315,31 @@ export function parsePolicyHeader(raw: string | null): RequestPolicy | undefined
315
315
  return Object.keys(out).length === 0 ? undefined : out;
316
316
  }
317
317
 
318
+ /**
319
+ * Parses the X-Omp-Upstream-Keys header: `{ "<upstream id>": "<credential>" }`.
320
+ * Malformed, not an object, or empty ⇒ no overrides, exactly like a malformed
321
+ * X-Omp-Policy (never a rejected turn — the configured keys still serve).
322
+ * Non-string values are dropped; `""` is KEPT, meaning "this turn has no
323
+ * credential for that upstream", which excludes it from selection.
324
+ */
325
+ export function parseUpstreamKeysHeader(raw: string | null): Record<string, string> | undefined {
326
+ if (raw === null || raw.trim() === "") return undefined;
327
+ let parsed: unknown;
328
+ try {
329
+ parsed = JSON.parse(raw);
330
+ } catch {
331
+ return undefined;
332
+ }
333
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
334
+ const out: Record<string, string> = {};
335
+ for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
336
+ // The id is config-shaped, so trim it; the credential is copied verbatim.
337
+ const key = id.trim();
338
+ if (key !== "" && typeof value === "string") out[key] = value;
339
+ }
340
+ return Object.keys(out).length === 0 ? undefined : out;
341
+ }
342
+
318
343
  export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
319
344
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
320
345
  throw invalidRequest("Request body must be a JSON object");
@@ -352,6 +377,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
352
377
  // Per-request routing policy (team edition): JSON in X-Omp-Policy.
353
378
  const policy = parsePolicyHeader(headers.get("x-omp-policy"));
354
379
 
380
+ // Per-turn upstream credentials (team edition): JSON in X-Omp-Upstream-Keys.
381
+ const upstreamKeys = parseUpstreamKeysHeader(headers.get("x-omp-upstream-keys"));
382
+
355
383
  if (typeof b.model !== "string" || b.model.length === 0) {
356
384
  throw invalidRequest("model must be a non-empty string");
357
385
  }
@@ -417,7 +445,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
417
445
  agentdoxOrigin,
418
446
  isSubagent,
419
447
  ...(policy === undefined ? {} : { policy }),
448
+ ...(upstreamKeys === undefined ? {} : { upstreamKeys }),
420
449
  requestedModel,
450
+ requestedModelFull: b.model,
421
451
  messages,
422
452
  tools,
423
453
  forcedToolChoice,