auto-model-router 0.35.0 → 0.37.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 (45) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +104 -10
  3. package/omp-extension/remote-logic.ts +15 -0
  4. package/package.json +1 -1
  5. package/src/catalog/benchmark-feeds.ts +179 -41
  6. package/src/catalog/openrouter-catalog.ts +10 -2
  7. package/src/cli/connect.ts +89 -17
  8. package/src/cli/context-token.ts +133 -0
  9. package/src/cli/credential-store.ts +65 -20
  10. package/src/cli/refresh.ts +26 -1
  11. package/src/config/defaults.ts +1 -0
  12. package/src/config/schema.ts +22 -0
  13. package/src/config/types.ts +31 -0
  14. package/src/cost/ledger-sql.ts +3 -2
  15. package/src/cost/ledger.ts +4 -0
  16. package/src/cost/types.ts +12 -0
  17. package/src/cost/views.ts +12 -0
  18. package/src/lib.ts +12 -0
  19. package/src/router/candidates.ts +17 -0
  20. package/src/router/index.ts +4 -0
  21. package/src/router/types.ts +2 -0
  22. package/src/server/catalog-view.ts +8 -2
  23. package/src/server/compaction-digest.ts +3 -0
  24. package/src/server/digest.ts +11 -0
  25. package/src/server/http.ts +46 -4
  26. package/src/server/turn.ts +5 -0
  27. package/src/util/requestid.ts +77 -0
  28. package/src/util/schema.ts +42 -3
  29. package/src/util/sqlite.ts +20 -1
  30. package/src/wire/openai/request.ts +8 -0
  31. package/src/wire/types.ts +18 -0
  32. package/test/benchmark-feeds.test.ts +194 -0
  33. package/test/catalog-view.test.ts +11 -0
  34. package/test/config.test.ts +26 -0
  35. package/test/context-token.test.ts +301 -0
  36. package/test/failover.test.ts +1 -1
  37. package/test/ledger-sql.test.ts +1 -1
  38. package/test/mcp-entry.test.ts +14 -5
  39. package/test/migrations.test.ts +11 -4
  40. package/test/reconfigure.test.ts +55 -0
  41. package/test/request-id.test.ts +424 -0
  42. package/test/schema.test.ts +2 -2
  43. package/test/tier-plan.test.ts +51 -0
  44. package/test/trust-attribution.test.ts +2 -2
  45. package/test/turn.test.ts +56 -1
@@ -7,6 +7,10 @@
7
7
  */
8
8
 
9
9
  import type { TaskType, Tier } from "../router/types.ts";
10
+ // Type-only, and deliberately so: `benchmarks.extraScores` is the same row shape
11
+ // the feeds produce, so there is one score vocabulary rather than two. The cycle
12
+ // (benchmark-feeds imports RouterConfig) is erased at compile time.
13
+ import type { FeedScore } from "../catalog/benchmark-feeds.ts";
10
14
 
11
15
  export type QualityAxis = "coding" | "agentic" | "intelligence";
12
16
 
@@ -173,6 +177,24 @@ export interface BenchmarksConfig {
173
177
  * e.g. after a data-collection window closes.
174
178
  */
175
179
  useLocalScores: boolean;
180
+ /**
181
+ * Scores supplied by the front door for axes the feeds leave empty. Applied by
182
+ * the SAME fill-only-missing rule as the feeds, AFTER Artificial Analysis and
183
+ * BenchLM and BEFORE `local`, so nothing a published source measured is ever
184
+ * moved. Empty by default.
185
+ *
186
+ * Provenance is each entry's `source`, and only two values are accepted:
187
+ * `neutral` (a benchmark's own leaderboard, taken as given) and `vendor` (a
188
+ * self-reported model-card number, which the supplier discounts before sending
189
+ * — the router never rescales a number it is handed). An entry claiming any
190
+ * other source is dropped: config must not be able to impersonate a published
191
+ * feed, nor write into the `local` lane that `useLocalScores` gates.
192
+ *
193
+ * Entries are sanitised on every use (`suppliedScores`), so a malformed one is
194
+ * dropped with a warning rather than failing a refresh or zeroing a score, and
195
+ * `key`/`creator` are normalised on the way in — the OpenRouter slug works.
196
+ */
197
+ extraScores?: FeedScore[];
176
198
  }
177
199
 
178
200
  /** Quality/price envelope for one complexity tier. */
@@ -226,6 +248,15 @@ export interface FilterConfig {
226
248
  allow: string[];
227
249
  /** Glob patterns; matching models are dropped. Applied after `allow`. */
228
250
  deny: string[];
251
+ /**
252
+ * Model-glob → provider-glob. A model matching a key may only dispatch
253
+ * through a provider whose id matches the value, so a team can keep a
254
+ * subscription's models on that subscription instead of its OpenRouter
255
+ * twins (`{"anthropic/*": "anthropic-subscription"}`). Applied before
256
+ * ranking; a model matching several locks must satisfy each. An empty
257
+ * object locks nothing.
258
+ */
259
+ providerLocks: Record<string, string>;
229
260
  /** Consider zero-price models. Off by default: rate limits make them expensive in retries. */
230
261
  includeFree: boolean;
231
262
  /** Require `supported_parameters` to include `tools` whenever the request offers tools. */
@@ -302,7 +302,7 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
302
302
  slug, served_slug, tier, classification_source, reasons, predicted_usd, reported_usd, usage,
303
303
  cost_breakdown, attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted,
304
304
  upstream_generation_id, error, error_kind, features, score, confidence, task, classifier_reasons,
305
- explored_from, hold_arm, prompt_tokens_saved, scope, redactions
305
+ explored_from, hold_arm, prompt_tokens_saved, scope, redactions, request_id
306
306
  ) VALUES (
307
307
  ${entry.id}, ${entry.createdAtMs}, ${entry.conversationKey}, ${entry.sessionId}, ${entry.turn},
308
308
  ${entry.requestedModel}, ${entry.harnessId}, ${entry.ompSessionId}, ${entry.slug}, ${entry.servedSlug},
@@ -313,7 +313,8 @@ export function createSqlLedger(db: SqlDb, cfg: RouterConfig, deps: LedgerDeps):
313
313
  ${jsonParam(db, entry.features)}, ${entry.score}, ${entry.confidence}, ${entry.task},
314
314
  ${jsonParam(db, entry.classifierReasons)}, ${entry.exploredFrom}, ${entry.holdArm},
315
315
  ${entry.promptTokensSaved},
316
- ${entry.scope === undefined || entry.scope === "" ? null : entry.scope}, ${entry.redactions ?? null}
316
+ ${entry.scope === undefined || entry.scope === "" ? null : entry.scope}, ${entry.redactions ?? null},
317
+ ${entry.requestId === undefined || entry.requestId === "" ? null : entry.requestId}
317
318
  )
318
319
  ON CONFLICT DO NOTHING`;
319
320
  };
@@ -82,6 +82,7 @@ export interface LedgerRow {
82
82
  prompt_tokens_saved: number | null;
83
83
  scope: string | null;
84
84
  redactions: number | null;
85
+ request_id: string | null;
85
86
  }
86
87
 
87
88
  interface TrustRow {
@@ -257,6 +258,9 @@ export function toEntry(row: LedgerRow): LedgerEntry {
257
258
  // Likewise a row from before v19, or a turn with redaction off: absent,
258
259
  // which is a different fact from 0 (the rules ran and matched nothing).
259
260
  ...(row.redactions === null || row.redactions === undefined ? {} : { redactions: row.redactions }),
261
+ // A row from before v20, or one the router wrote for a caller that named
262
+ // no request: absent, and never guessed at from anything else.
263
+ ...(row.request_id === null || row.request_id === undefined ? {} : { requestId: row.request_id }),
260
264
  // A row recorded before pricing, or one whose model could not be priced,
261
265
  // stores NULL; absent is the front door's cue to fall back to the blend.
262
266
  ...(row.cost_breakdown === null || row.cost_breakdown === undefined ? {} : { costBreakdown: JSON.parse(row.cost_breakdown) as CostBreakdown }),
package/src/cost/types.ts CHANGED
@@ -161,6 +161,18 @@ export interface LedgerEntry {
161
161
  * on every row written before v19; 0 means the rules ran and matched nothing.
162
162
  */
163
163
  redactions?: number;
164
+ /**
165
+ * The id of the HTTP request this row belongs to: the `X-Request-Id` the
166
+ * caller sent, or the one the router minted for a caller that sent none
167
+ * (prefixed `amr-`, so the two are never confused — see
168
+ * `util/requestid.ts`). An escalated turn writes several rows under one id.
169
+ *
170
+ * This is what turns "my request id was abc123" into exactly this turn. A
171
+ * front door that had to guess from member and time can stop guessing.
172
+ * Absent on every row written before v20, and on the router's own side
173
+ * calls whose caller named no request.
174
+ */
175
+ requestId?: string;
164
176
  /**
165
177
  * The catalog model that served, for the cost split. The ledger can price
166
178
  * OpenRouter slugs from its own cached catalog payload; a model from another
package/src/cost/views.ts CHANGED
@@ -82,6 +82,13 @@ export interface DecisionFilter {
82
82
  tier?: string;
83
83
  /** Only one omp session (`/router why`). */
84
84
  ompSessionId?: string;
85
+ /**
86
+ * Only the rows written for one request id — the `x-request-id` a customer
87
+ * quotes. Every attempt of an escalated turn shares it, so this is the
88
+ * whole turn rather than one dispatch. Exact: an id nothing was recorded
89
+ * under returns nothing, which is a different answer from a guess.
90
+ */
91
+ requestId?: string;
85
92
  }
86
93
 
87
94
  /**
@@ -107,6 +114,11 @@ export async function decisionEntries(db: SqlDb, filter: DecisionFilter): Promis
107
114
  where.push("omp_session_id = $session");
108
115
  bind.$session = filter.ompSessionId;
109
116
  }
117
+ if (filter.requestId !== undefined && filter.requestId !== "") {
118
+ // Indexed, and the only exact way in: `idx_ledger_request`.
119
+ where.push("request_id = $requestId");
120
+ bind.$requestId = filter.requestId;
121
+ }
110
122
  const limit = Math.min(Math.max(filter.limit ?? 50, 1), 1_000);
111
123
  const rows = await db.query<unknown>(
112
124
  `SELECT * FROM ledger WHERE ${where.join(" AND ")} ORDER BY created_at_ms DESC LIMIT ${limit}`,
package/src/lib.ts CHANGED
@@ -20,6 +20,14 @@ export type { RedactionConfig, RedactionRule, RouterConfig, UpstreamEntry, Upstr
20
20
  // the same guard the router refuses it with, before the rule is ever saved.
21
21
  export { validateRedactionPattern, validateRedactionRule, defaultReplacement, MAX_REDACTION_RULES } from "./config/redaction.ts";
22
22
  export { RESERVED_UPSTREAM_IDS } from "./config/schema.ts";
23
+ // `benchmarks.extraScores`: a front door supplies scores for axes the feeds leave
24
+ // empty. The row shape and the two provenances config may claim are exported so
25
+ // it can build and check the table against the same rules the router applies —
26
+ // including the cap, past which the excess is dropped. There is deliberately no
27
+ // writer for `local_scores` here: that table belongs to the eval runner and is
28
+ // gated by `benchmarks.useLocalScores`, and a front door writing into it would
29
+ // make the router's own switch a lie.
30
+ export { MAX_EXTRA_SCORES, SUPPLIED_SOURCES, FILL_ORDER, type FeedScore, type FeedSource } from "./catalog/benchmark-feeds.ts";
23
31
  export { setKnownUpstreamIds, providerOfSlug } from "./cost/report.ts";
24
32
  export type { DeepPartial } from "./config/load.ts";
25
33
  export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
@@ -40,3 +48,7 @@ export type { AsyncLedger, LedgerEntry, PruneResult } from "./cost/types.ts";
40
48
  // Retention: a front door asks through `POST /v1/router/prune` rather than
41
49
  // deleting from the ledger itself. The interval is exported so it can say when.
42
50
  export { RETENTION_INTERVAL_MS } from "./cost/retention.ts";
51
+ // Request ids. A front door that stamps `x-request-id` and then looks a turn up
52
+ // by it judges an id with the SAME rules the router carried it under, and tells
53
+ // a minted id from one its own edge assigned — rather than reimplementing either.
54
+ export { acceptRequestId, isRequestId, isMintedRequestId, mintRequestId, requestIdFor, MINTED_REQUEST_ID_PREFIX, REQUEST_ID_MAX_LENGTH } from "./util/requestid.ts";
@@ -164,6 +164,12 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
164
164
  const relaxTrust = relaxLevel >= 3;
165
165
  const allowRes = filters.allow.map(globToRe);
166
166
  const denyRes = filters.deny.map(globToRe);
167
+ // Compiled once per turn: pairs of [model glob, provider glob] regexes.
168
+ // Null when there are no locks, so the common path allocates nothing.
169
+ const lockRes =
170
+ filters.providerLocks && Object.keys(filters.providerLocks).length > 0
171
+ ? Object.entries(filters.providerLocks).map(([m, p]) => [globToRe(m), globToRe(p)] as const)
172
+ : null;
167
173
  const needTools = req.tools.length > 0 && filters.requireToolSupport;
168
174
  const minContext = Math.ceil(features.promptTokens * filters.contextHeadroom) + expectedCompletionTokens;
169
175
  // Task selects the quality axis and capability filters; the tier still
@@ -237,6 +243,17 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
237
243
  rejected.push({ slug, reason: "denylisted", detail: "filters.deny" });
238
244
  continue;
239
245
  }
246
+ // Provider locks: where a model may be served from. Checked like a
247
+ // filter (a lock the model cannot satisfy drops it before ranking)
248
+ // rather than at dispatch, so the catalog view, the rejection list and
249
+ // the turn all agree on what was available.
250
+ if (lockRes !== null) {
251
+ const violated = lockRes.find(([modelRe, providerRe]) => modelRe.test(slug) && !providerRe.test(model.provider));
252
+ if (violated !== undefined) {
253
+ rejected.push({ slug, reason: "provider_locked", detail: `${slug} is locked to providers matching "${violated[1].source}"` });
254
+ continue;
255
+ }
256
+ }
240
257
  if (model.isFree && !filters.includeFree) {
241
258
  rejected.push({ slug, reason: "free_tier_excluded" });
242
259
  continue;
@@ -65,6 +65,10 @@ export function applyRequestPolicy(
65
65
  const outProfile = narrowed ? { ...profile, id: `${profile.id}+policy`, minTier, maxTier } : profile;
66
66
  if (narrowed) reasons.push(`policy: tiers narrowed to [${minTier}..${maxTier}]`);
67
67
  let outCfg = cfg;
68
+ if (policy.providerLocks !== undefined) {
69
+ outCfg = { ...cfg, filters: { ...cfg.filters, providerLocks: { ...cfg.filters.providerLocks, ...policy.providerLocks } } };
70
+ reasons.push(`policy: provider locks ${Object.entries(policy.providerLocks).map(([m, p]) => `${m}→${p}`).join(" ")}`);
71
+ }
68
72
  if (policy.allow !== undefined || policy.deny !== undefined) {
69
73
  outCfg = { ...cfg, filters: { ...cfg.filters, ...(policy.allow === undefined ? {} : { allow: policy.allow }), ...(policy.deny === undefined ? {} : { deny: [...cfg.filters.deny, ...policy.deny] }) } };
70
74
  reasons.push(`policy: ${policy.allow === undefined ? "" : `allow ${policy.allow.join("|")} `}${policy.deny === undefined ? "" : `deny ${policy.deny.join("|")}`}`.trim());
@@ -162,6 +162,8 @@ export type RejectionReason =
162
162
  | "denylisted"
163
163
  | "not_allowlisted"
164
164
  | "free_tier_excluded"
165
+ /** filters.providerLocks: the model matches a lock whose provider glob excludes its upstream. */
166
+ | "provider_locked"
165
167
  | "reasoning_mandatory"
166
168
  | "untrusted"
167
169
  /** The turn's `X-Omp-Upstream-Keys` names this model's upstream with an empty credential: it cannot be dispatched to. */
@@ -49,7 +49,7 @@ export interface CatalogView {
49
49
  }
50
50
 
51
51
  /** The filters a turn routes under: the configured ones, or those `applyRequestPolicy` merged a policy into. */
52
- export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "includeFree" | "requireToolSupport">;
52
+ export type AdmissionFilters = Pick<FilterConfig, "allow" | "deny" | "providerLocks" | "includeFree" | "requireToolSupport">;
53
53
 
54
54
  export interface CatalogViewArgs {
55
55
  models: readonly CatalogModel[];
@@ -84,11 +84,17 @@ function filterReason(model: CatalogModel, filters: AdmissionFilters, allowRes:
84
84
  if (allowRes.length > 0 && !allowRes.some((re) => re.test(model.slug))) return "not in the allow list";
85
85
  const denied = denyRes.findIndex((re) => re.test(model.slug));
86
86
  if (denied !== -1) return `denied by ${filters.deny[denied]}`;
87
+ if (filters.providerLocks) {
88
+ for (const [modelGlob, providerGlob] of Object.entries(filters.providerLocks)) {
89
+ if (globToRe(modelGlob).test(model.slug) && !globToRe(providerGlob).test(model.provider)) {
90
+ return `locked to providers matching ${providerGlob} (filters.providerLocks)`;
91
+ }
92
+ }
93
+ }
87
94
  if (model.isFree && !filters.includeFree) return "free models excluded (filters.includeFree)";
88
95
  if (filters.requireToolSupport && !model.supportsTools) return "no tool support (filters.requireToolSupport)";
89
96
  return null;
90
97
  }
91
-
92
98
  export function catalogView(args: CatalogViewArgs): CatalogView {
93
99
  const sorted = [...args.models].sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
94
100
  const verdict = args.verdict;
@@ -107,6 +107,9 @@ export async function digestCompactionEdits(args: DigestCompactionArgs): Promise
107
107
  query,
108
108
  tier,
109
109
  source: "compaction",
110
+ // The turn's own request id: the digest row and the turn row are
111
+ // then one request, which is how they are read in support.
112
+ ...(req.requestId === undefined ? {} : { requestId: req.requestId }),
110
113
  });
111
114
  return { edit: e, result };
112
115
  } catch (err) {
@@ -48,6 +48,14 @@ export interface DigestRequest {
48
48
  * and is gated on `compaction.digestToolResults` instead.
49
49
  */
50
50
  source?: "tool_result" | "compaction";
51
+ /**
52
+ * The HTTP request this digest belongs to, recorded on its ledger row. A
53
+ * digest taken DURING a turn carries that turn's id, so the cheap side call
54
+ * and the turn it saved money on answer to one request id; a standalone
55
+ * digest carries whatever its own caller sent, and nothing when it sent
56
+ * none (a side call is not a turn, so none is minted for it).
57
+ */
58
+ requestId?: string;
51
59
  }
52
60
 
53
61
  export type DigestResult =
@@ -260,6 +268,9 @@ export function createDigester(deps: DigesterDeps): Digester {
260
268
  upstreamGenerationId: null,
261
269
  error,
262
270
  promptTokensSaved: 0,
271
+ // The request this digest served, when it has one: inside a turn that
272
+ // is the turn's id, so both rows answer to what a customer quotes.
273
+ ...(req.requestId === undefined || req.requestId === "" ? {} : { requestId: req.requestId }),
263
274
  priceModel: model,
264
275
  };
265
276
  try {
@@ -41,6 +41,7 @@ import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
41
41
  import type { RouterConfig } from "../config/types.ts";
42
42
  import { createLogger } from "../util/log.ts";
43
43
  import { openDb } from "../util/sqlite.ts";
44
+ import { acceptRequestId, isRequestId } from "../util/requestid.ts";
44
45
  import { dialectOf, openSqlDb } from "../util/sql.ts";
45
46
  import { migrateStore } from "../util/schema.ts";
46
47
  import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
@@ -275,7 +276,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
275
276
  const postgres = dialectOf(storePath) === "postgres";
276
277
  const cachePath = postgres ? join(routerHome(), "cache.db") : storePath;
277
278
  mkdirSync(dirname(cachePath), { recursive: true });
278
- // `openDb` is the migration path for a SQLite file: nineteen versions, in
279
+ // `openDb` is the migration path for a SQLite file: twenty versions, in
279
280
  // order, on whatever an older release left behind.
280
281
  const db = openDb(cachePath);
281
282
  const sqlDb = openSqlDb(storePath);
@@ -510,7 +511,25 @@ export function startServer(cfg: RouterConfig): StartedServer {
510
511
  releaseTurn();
511
512
  });
512
513
 
513
- return response;
514
+ // The id this turn's ledger rows are filed under, echoed so the caller
515
+ // holds it: a front door that sent one gets its own value back
516
+ // unchanged, and a direct caller that sent none learns the minted id it
517
+ // would otherwise never see. Set on the response object the wire just
518
+ // built (a fresh `new Response`, so its headers are still mutable) —
519
+ // for a buffered turn that is a promise, awaited here as it was before.
520
+ const stamp = async (r: Response | Promise<Response>): Promise<Response> => {
521
+ const res = await r;
522
+ if (normReq.requestId !== undefined) {
523
+ try {
524
+ res.headers.set("x-request-id", normReq.requestId);
525
+ } catch {
526
+ // An immutable response: the id still reached the ledger, which is
527
+ // what support reads. Never fail a served turn over a header.
528
+ }
529
+ }
530
+ return res;
531
+ };
532
+ return stamp(response);
514
533
  };
515
534
 
516
535
  const server: Server<undefined> = Bun.serve({
@@ -692,13 +711,26 @@ export function startServer(cfg: RouterConfig): StartedServer {
692
711
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
693
712
  // The decision trail, newest first. ?session=<omp session id> narrows to one
694
713
  // session (/router why); ?harness=a,b to a harness set (a team's user or group),
695
- // ?since=<ms> or ?days=N to a window, ?slug= and ?tier= to a model or a tier.
714
+ // ?since=<ms> or ?days=N to a window, ?slug= and ?tier= to a model or a tier,
715
+ // and ?requestId= to the rows of ONE request — the id a customer quotes,
716
+ // which an escalated turn's every attempt shares. That last one is
717
+ // exact: an id nothing was recorded under answers with no entries
718
+ // rather than with the nearest turn in time.
696
719
  const rawLimit = url.searchParams.get("limit");
697
720
  const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
698
721
  const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
699
722
  const sinceRaw = Number.parseInt(url.searchParams.get("since") ?? "", 10);
700
723
  const daysRaw = url.searchParams.get("days");
701
724
  const sinceMs = Number.isFinite(sinceRaw) ? sinceRaw : daysRaw === null ? 0 : Date.now() - clampDays(daysRaw, 30) * 86_400_000;
725
+ // A lookup accepts a MINTED id as readily as a caller's — the header
726
+ // refuses the prefix so nobody can claim to have minted one, but
727
+ // asking about one the router minted is the ordinary case. An id
728
+ // this router could never have recorded is a mangled paste, and
729
+ // saying so beats silently answering with the whole trail.
730
+ const askedRequestId = (url.searchParams.get("requestId") ?? "").trim();
731
+ if (askedRequestId !== "" && !isRequestId(askedRequestId)) {
732
+ return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "requestId is not a request id this router records" });
733
+ }
702
734
  const entries = await decisionEntries(sqlDb, {
703
735
  sinceMs,
704
736
  harness: harnessScopeParam(url.searchParams.get("harness")),
@@ -706,6 +738,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
706
738
  slug: url.searchParams.get("slug") ?? "",
707
739
  tier: url.searchParams.get("tier") ?? "",
708
740
  ompSessionId: url.searchParams.get("session") ?? "",
741
+ requestId: askedRequestId,
709
742
  });
710
743
  return json({ entries });
711
744
  }
@@ -757,6 +790,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
757
790
  input: typeof body.input === "object" && body.input !== null ? (body.input as Record<string, unknown>) : {},
758
791
  content: body.content,
759
792
  query: typeof body.query === "string" ? body.query : "",
793
+ // The caller's own request id when it sent one. None is minted
794
+ // here: a digest is a side call, and an id invented for it would
795
+ // address a row no client could ever ask about.
796
+ ...(acceptRequestId(req.headers.get("x-request-id")) === "" ? {} : { requestId: acceptRequestId(req.headers.get("x-request-id")) }),
760
797
  }),
761
798
  );
762
799
  }
@@ -923,7 +960,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
923
960
  // `upstream-keys` would have them ignored, and its tenants served on
924
961
  // the deployment's own credential — a silent cross-charge. A name it
925
962
  // can check turns that into a refusal it can explain.
926
- features: ["upstream-keys"],
963
+ // `request-id`: this router reads `X-Request-Id` on a turn and
964
+ // records it on the ledger row, so a front door can stop matching
965
+ // its own request log against the ledger by member and time and
966
+ // read the id off the row instead. One without the name still
967
+ // serves every turn; its front door keeps the approximate join.
968
+ features: ["upstream-keys", "request-id"],
927
969
  apiKeyConfigured: cfg.openrouter.apiKey !== "",
928
970
  // Which upstreams turns can actually be served from: OpenRouter needs
929
971
  // its key; Ollama needs to be on and out of cooldown.
@@ -349,6 +349,11 @@ export async function runTurn(
349
349
  // row back to a project. The resolved one the bridge used, header or
350
350
  // configured default; "" stores as NULL.
351
351
  scope: doxScope,
352
+ // The HTTP request this turn is answering, so a request id quoted in
353
+ // a ticket names the turn exactly rather than by member and time. The
354
+ // wire has already decided it: the caller's header, or a minted one.
355
+ // Every attempt of the turn is recorded under the same id.
356
+ ...(req.requestId === undefined ? {} : { requestId: req.requestId }),
352
357
  ompSessionId: req.ompSessionId,
353
358
  slug: decision.slug,
354
359
  servedSlug,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The request id a turn carries, end to end.
3
+ *
4
+ * A front door (the team edition, a proxy, an edge) stamps `x-request-id` on
5
+ * the response a customer sees, and that is what they quote in a ticket. Until
6
+ * the router recorded it, placing that id on a ROUTED TURN was a time-window
7
+ * join — "the same member at the same instant" — which can only ever answer
8
+ * "probably this one", and answers nothing at all when two turns overlap. The
9
+ * ledger records the id instead, so the answer is exact.
10
+ *
11
+ * The value comes from outside, so it is validated rather than trusted:
12
+ *
13
+ * - **Bounded.** 128 characters covers every id in circulation (a UUID is 36,
14
+ * a ULID 26, a W3C `traceparent` 55, the team edition's own 12) with room
15
+ * to spare, and nothing unbounded reaches a column, a log line or a JSON
16
+ * body.
17
+ * - **Safe characters only.** Letters, digits, and `. _ - : + =`. That is
18
+ * every id shape a caller actually sends, and it excludes by construction
19
+ * the things that make an opaque string dangerous downstream: control
20
+ * characters and newlines (log injection — a log line must stay one line),
21
+ * quotes and angle brackets (a front door rendering the id in HTML), and
22
+ * whitespace.
23
+ * - **Rejected, never repaired.** A value outside the rules is treated as
24
+ * absent, not truncated or stripped. A truncated id looks valid and matches
25
+ * nothing, which is worse than a fresh one that at least addresses the row.
26
+ *
27
+ * When the caller sends nothing usable the router MINTS one, so every turn it
28
+ * records is addressable — a direct user of the router gets the capability the
29
+ * team edition's members get, without a front door in between. A minted id
30
+ * carries a fixed prefix so it is never confused with one a caller chose, and
31
+ * a caller-supplied id that wears the prefix is refused for the same reason:
32
+ * nobody but this router may claim to have minted an id.
33
+ */
34
+
35
+ /** The longest id accepted from a caller, and the length of a minted one's body. */
36
+ export const REQUEST_ID_MAX_LENGTH = 128;
37
+
38
+ /**
39
+ * What every id this router mints starts with. Chosen to survive a front door's
40
+ * own id rules: it stays inside `[A-Za-z0-9._-]`, so an operator can paste a
41
+ * minted id into the team edition's support search unchanged.
42
+ */
43
+ export const MINTED_REQUEST_ID_PREFIX = "amr-";
44
+
45
+ const SHAPE = new RegExp(`^[A-Za-z0-9._:+=-]{1,${REQUEST_ID_MAX_LENGTH}}$`);
46
+
47
+ /** Whether a string is an acceptable request id at all (shape and length only). */
48
+ export function isRequestId(value: string): boolean {
49
+ return SHAPE.test(value);
50
+ }
51
+
52
+ /** Whether this router minted the id, rather than a caller supplying it. */
53
+ export function isMintedRequestId(value: string): boolean {
54
+ return value.startsWith(MINTED_REQUEST_ID_PREFIX);
55
+ }
56
+
57
+ /** A fresh id for a turn whose caller supplied none: the prefix plus 32 hex characters. */
58
+ export function mintRequestId(): string {
59
+ return `${MINTED_REQUEST_ID_PREFIX}${crypto.randomUUID().replaceAll("-", "")}`;
60
+ }
61
+
62
+ /**
63
+ * The id to trust from a request header: the caller's, or `""` for anything
64
+ * this router will not carry — absent, empty, too long, the wrong characters,
65
+ * or wearing the minted prefix.
66
+ */
67
+ export function acceptRequestId(raw: string | null | undefined): string {
68
+ const s = (raw ?? "").trim();
69
+ if (!isRequestId(s)) return "";
70
+ return isMintedRequestId(s) ? "" : s;
71
+ }
72
+
73
+ /** The id a turn carries: the caller's when it passes, otherwise a minted one. */
74
+ export function requestIdFor(raw: string | null | undefined): string {
75
+ const given = acceptRequestId(raw);
76
+ return given === "" ? mintRequestId() : given;
77
+ }
@@ -2,7 +2,7 @@
2
2
  * The store's schema, for either engine.
3
3
  *
4
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
5
+ * exists: twenty versions have shipped, and an old file still needs its
6
6
  * `ALTER TABLE`s applied in order. This module declares the FINAL shape of
7
7
  * every table instead, which is what a fresh store needs — a Postgres database
8
8
  * has no history to migrate, and on an already-migrated file every statement
@@ -68,6 +68,34 @@ async function createIfAbsent(db: SqlDb, statement: string): Promise<void> {
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * Columns added to `ledger` AFTER this shim shipped, as `<name> <type>`.
73
+ *
74
+ * `CREATE TABLE IF NOT EXISTS` declares the final shape for a store that does
75
+ * not exist yet and does exactly nothing for one that does — so a Postgres
76
+ * deployment that has been running since before a column was added would never
77
+ * grow it, and the very next turn would fail its INSERT on an unknown column.
78
+ * SQLite reaches the same place through `util/sqlite.ts`'s guarded ALTERs;
79
+ * this is that path for the other engine, and it stays a list rather than a
80
+ * version counter because `ADD COLUMN IF NOT EXISTS` is already the guard.
81
+ *
82
+ * On a partitioned ledger the ALTER applies to the parent and every partition
83
+ * with it, which is what keeps a day's table from diverging from its parent.
84
+ */
85
+ const PG_LEDGER_COLUMNS = ["request_id TEXT"] as const;
86
+
87
+ /** `42701` is "column already exists": another replica added it between the check and the ALTER. */
88
+ const ADDED_ALREADY = new Set([...RACED, "42701"]);
89
+
90
+ async function addColumnIfAbsent(db: SqlDb, table: string, column: string): Promise<void> {
91
+ try {
92
+ await db.sql.unsafe(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${column}`);
93
+ } catch (err) {
94
+ const code = err !== null && typeof err === "object" && "errno" in err ? String(err.errno) : "";
95
+ if (!ADDED_ALREADY.has(code)) throw err;
96
+ }
97
+ }
98
+
71
99
  /**
72
100
  * How this store holds the ledger.
73
101
  *
@@ -157,7 +185,7 @@ export async function migrateStore(db: SqlDb, log?: Logger): Promise<void> {
157
185
  singleton("benchmark_cache"),
158
186
  singleton("local_scores"),
159
187
 
160
- // One row per dispatched upstream generation. The columns nineteen
188
+ // One row per dispatched upstream generation. The columns twenty
161
189
  // migrations added are declared here as they finally stand.
162
190
  //
163
191
  // Partitioned by day on Postgres, which forces the primary key to
@@ -201,7 +229,8 @@ export async function migrateStore(db: SqlDb, log?: Logger): Promise<void> {
201
229
  hold_arm INTEGER,
202
230
  prompt_tokens_saved INTEGER,
203
231
  scope TEXT,
204
- redactions INTEGER${partitioned ? ",\n\t\t\tPRIMARY KEY (id, created_at_ms)" : ""}
232
+ redactions INTEGER,
233
+ request_id TEXT${partitioned ? ",\n\t\t\tPRIMARY KEY (id, created_at_ms)" : ""}
205
234
  )${partitioned ? " PARTITION BY RANGE (created_at_ms)" : ""}`,
206
235
  // The composite key above cannot serve a lookup by `id` alone, which is
207
236
  // what the feedback join and `markWasted` do.
@@ -216,6 +245,9 @@ export async function migrateStore(db: SqlDb, log?: Logger): Promise<void> {
216
245
  "CREATE INDEX IF NOT EXISTS idx_ledger_harness_created ON ledger (harness_id, created_at_ms DESC)",
217
246
  "CREATE INDEX IF NOT EXISTS idx_ledger_slug_harness_created ON ledger (slug, harness_id, created_at_ms DESC)",
218
247
  "CREATE INDEX IF NOT EXISTS idx_ledger_session ON ledger (omp_session_id, created_at_ms DESC)",
248
+ // One request id straight to its rows: what support does with an id a
249
+ // customer quoted, and an escalated turn files several rows under it.
250
+ "CREATE INDEX IF NOT EXISTS idx_ledger_request ON ledger (request_id)",
219
251
 
220
252
  `CREATE TABLE IF NOT EXISTS token_calibration (
221
253
  tokenizer TEXT PRIMARY KEY,
@@ -293,6 +325,13 @@ export async function migrateStore(db: SqlDb, log?: Logger): Promise<void> {
293
325
  )`,
294
326
  ];
295
327
 
328
+ // Before the statements, so the indexes below can name a column an existing
329
+ // deployment is only now growing. A store that has no ledger yet gets the
330
+ // whole shape from the CREATE TABLE and needs no top-up.
331
+ if (db.dialect === "postgres" && (await db.tableExists("ledger"))) {
332
+ for (const column of PG_LEDGER_COLUMNS) await addColumnIfAbsent(db, "ledger", column);
333
+ }
334
+
296
335
  for (const statement of statements) await createIfAbsent(db, statement);
297
336
 
298
337
  if (db.dialect !== "postgres") return;
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 19;
21
+ const USER_VERSION = 20;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -306,6 +306,24 @@ const MIGRATE_V19 = `
306
306
  ALTER TABLE ledger ADD COLUMN redactions INTEGER;
307
307
  `;
308
308
 
309
+ // v20: ledger records the id of the HTTP request the turn answered — the
310
+ // `X-Request-Id` a front door stamps on the response a customer sees, or one
311
+ // the router minted for a caller that sent none (`util/requestid.ts`). Without
312
+ // it, placing a quoted request id on a turn was a join on member and time,
313
+ // which can only answer "probably this one" and answers nothing when two turns
314
+ // of one member overlap.
315
+ //
316
+ // Indexed because that lookup — one id, straight to its rows — is the whole
317
+ // point of the column, and an escalated turn writes several rows under the
318
+ // same id. NULL on every row written before this, and on the router's own side
319
+ // calls (a standalone digest whose caller named no request); there is nothing
320
+ // to backfill from, and a time-window guess written into the column would be
321
+ // indistinguishable from a fact.
322
+ const MIGRATE_V20 = `
323
+ ALTER TABLE ledger ADD COLUMN request_id TEXT;
324
+ CREATE INDEX IF NOT EXISTS idx_ledger_request ON ledger (request_id);
325
+ `;
326
+
309
327
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
310
328
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
311
329
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -360,6 +378,7 @@ export function openDb(path: string): Database {
360
378
  if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
361
379
  if (!ledgerCols.some((c) => c.name === "scope")) db.exec(MIGRATE_V18);
362
380
  if (!ledgerCols.some((c) => c.name === "redactions")) db.exec(MIGRATE_V19);
381
+ if (!ledgerCols.some((c) => c.name === "request_id")) db.exec(MIGRATE_V20);
363
382
  const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
364
383
  if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
365
384
  if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
@@ -11,6 +11,7 @@ import type {
11
11
  UpstreamMutations,
12
12
  } from "../types.ts";
13
13
  import { conversationKeyOf } from "../../util/hash.ts";
14
+ import { requestIdFor } from "../../util/requestid.ts";
14
15
  import { invalidRequest, modelNotFound } from "./errors.ts";
15
16
 
16
17
  const ROLES: Record<string, true> = { system: true, developer: true, user: true, assistant: true, tool: true };
@@ -374,6 +375,12 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
374
375
  // Subagent marker from the embed extension (sessions without a UI).
375
376
  const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
376
377
 
378
+ // The id of the request this turn answers, recorded on every ledger row the
379
+ // turn writes. A front door's `X-Request-Id` when it passes the rules
380
+ // (bounded, safe characters, not wearing the minted prefix); otherwise one
381
+ // minted here, so a turn is addressable whether or not a caller named it.
382
+ const requestId = requestIdFor(headers.get("x-request-id"));
383
+
377
384
  // Per-request routing policy (team edition): JSON in X-Omp-Policy.
378
385
  const policy = parsePolicyHeader(headers.get("x-omp-policy"));
379
386
 
@@ -444,6 +451,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
444
451
  agentdoxPersonal,
445
452
  agentdoxOrigin,
446
453
  isSubagent,
454
+ requestId,
447
455
  ...(policy === undefined ? {} : { policy }),
448
456
  ...(upstreamKeys === undefined ? {} : { upstreamKeys }),
449
457
  requestedModel,