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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +104 -10
- package/omp-extension/remote-logic.ts +15 -0
- package/package.json +1 -1
- package/src/catalog/benchmark-feeds.ts +179 -41
- package/src/catalog/openrouter-catalog.ts +10 -2
- package/src/cli/connect.ts +89 -17
- package/src/cli/context-token.ts +133 -0
- package/src/cli/credential-store.ts +65 -20
- package/src/cli/refresh.ts +26 -1
- package/src/config/defaults.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/config/types.ts +31 -0
- package/src/cost/ledger-sql.ts +3 -2
- package/src/cost/ledger.ts +4 -0
- package/src/cost/types.ts +12 -0
- package/src/cost/views.ts +12 -0
- package/src/lib.ts +12 -0
- package/src/router/candidates.ts +17 -0
- package/src/router/index.ts +4 -0
- package/src/router/types.ts +2 -0
- package/src/server/catalog-view.ts +8 -2
- package/src/server/compaction-digest.ts +3 -0
- package/src/server/digest.ts +11 -0
- package/src/server/http.ts +46 -4
- package/src/server/turn.ts +5 -0
- package/src/util/requestid.ts +77 -0
- package/src/util/schema.ts +42 -3
- package/src/util/sqlite.ts +20 -1
- package/src/wire/openai/request.ts +8 -0
- package/src/wire/types.ts +18 -0
- package/test/benchmark-feeds.test.ts +194 -0
- package/test/catalog-view.test.ts +11 -0
- package/test/config.test.ts +26 -0
- package/test/context-token.test.ts +301 -0
- package/test/failover.test.ts +1 -1
- package/test/ledger-sql.test.ts +1 -1
- package/test/mcp-entry.test.ts +14 -5
- package/test/migrations.test.ts +11 -4
- package/test/reconfigure.test.ts +55 -0
- package/test/request-id.test.ts +424 -0
- package/test/schema.test.ts +2 -2
- package/test/tier-plan.test.ts +51 -0
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +56 -1
package/src/config/types.ts
CHANGED
|
@@ -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. */
|
package/src/cost/ledger-sql.ts
CHANGED
|
@@ -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
|
};
|
package/src/cost/ledger.ts
CHANGED
|
@@ -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";
|
package/src/router/candidates.ts
CHANGED
|
@@ -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;
|
package/src/router/index.ts
CHANGED
|
@@ -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());
|
package/src/router/types.ts
CHANGED
|
@@ -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) {
|
package/src/server/digest.ts
CHANGED
|
@@ -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 {
|
package/src/server/http.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/server/turn.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/util/schema.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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;
|
package/src/util/sqlite.ts
CHANGED
|
@@ -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 =
|
|
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,
|