@metrxbot/sdk 0.2.0 → 0.2.2

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.
@@ -0,0 +1,685 @@
1
+ /**
2
+ * SDK cascade routing module — `runWithPolicy()` (spec
3
+ * docs/roi-engine-cascade-sdk-phase1-spec.md §5, Phase 1 S2).
4
+ *
5
+ * Bring-your-own-caller: the customer supplies the function that calls their
6
+ * provider with THEIR keys; this module supplies policy consultation, the
7
+ * confidence gate, escalation, holdout randomization, and evidence emission.
8
+ *
9
+ * THE INVARIANT (throw-parity, §5.7): no code path in runWithPolicy throws an
10
+ * error that the customer's own `callModel(prodModel)` would not have thrown.
11
+ * Policy fetch, gate, candidate calls, and every emit fail OPEN; only the
12
+ * production-model call's own errors propagate.
13
+ *
14
+ * Streaming is OUT OF SCOPE for v1 (§5.1 H7): this contract handles complete
15
+ * responses only — keep streaming call sites on their original path.
16
+ */
17
+ import { PolicyCache, normalizeAgentKey, } from './routing-policy-cache';
18
+ import { runValidators, runChecker, withTimeout } from './routing-gate';
19
+ import { currentTaskId } from './task';
20
+ /** Bump with package.json (atomic-version rule). Server can force pass-through
21
+ * for known-bad versions via routing_policies.min_sdk_version (F10).
22
+ * 0.2.2 = the FIRST published version carrying WS1 (task-keyed holdout draw,
23
+ * #260) — required for `randomization_unit:'task'` policies to draw causal arms.
24
+ * The npm-published 0.2.1 predates WS1; min_sdk_version must be '0.2.2'+ to gate. */
25
+ export const SDK_VERSION = '0.2.2';
26
+ const EMIT_TIMEOUT_MS = 2000; // house SDK pattern: fire-and-forget, bounded
27
+ const JUDGE_TIMEOUT_MS = 10000; // background judging; a hung judge must not pin flush() (M3)
28
+ const BREAKER_ERROR_THRESHOLD = 5;
29
+ const BREAKER_WINDOW_MS = 10 * 60 * 1000;
30
+ const BREAKER_COOLDOWN_MS = 10 * 60 * 1000;
31
+ const BREAKER_ESCALATION_WINDOW = 50;
32
+ const BREAKER_ESCALATION_RATE = 0.9;
33
+ /**
34
+ * WS1 (Phase 3): deterministic map of a string to a uniform value in [0, 1).
35
+ * FNV-1a (32-bit) — stable across runs/processes and well-distributed, so the
36
+ * empirical treatment fraction over many task ids matches `holdout_pct`. Used to
37
+ * make the holdout arm a function of the TASK id (whole-task treatment) so
38
+ * multi-request tasks are arm-consistent — the prerequisite for task-level
39
+ * causal value evidence (Phase 3 D10). Mirrors the gateway's deterministicBucket
40
+ * (apps/gateway/src/model-routing.ts) but returns a unit float, not a %-bucket.
41
+ */
42
+ export function hashToUnit(input) {
43
+ let h = 0x811c9dc5;
44
+ for (let i = 0; i < input.length; i++) {
45
+ h ^= input.charCodeAt(i);
46
+ h = Math.imul(h, 0x01000193);
47
+ }
48
+ return (h >>> 0) / 0x100000000;
49
+ }
50
+ export class MetrxRouter {
51
+ cfg;
52
+ cache;
53
+ now;
54
+ random;
55
+ fetchImpl;
56
+ breakers = new Map();
57
+ background = new Set();
58
+ constructor(cfg) {
59
+ this.cfg = cfg;
60
+ this.now = cfg.now ?? Date.now;
61
+ this.random = cfg.random ?? Math.random;
62
+ this.fetchImpl = cfg.fetchImpl ?? fetch;
63
+ this.cache = new PolicyCache({
64
+ fetcher: this.makePolicyFetcher(),
65
+ ttlMs: cfg.policyTtlMs,
66
+ staleMaxMs: cfg.policyStaleMaxMs,
67
+ now: this.now,
68
+ });
69
+ }
70
+ /** Await all in-flight background work (emits, shadow calls, judging).
71
+ * For tests and graceful shutdown — never required for correctness. */
72
+ async flush() {
73
+ await this.cache.settle();
74
+ while (this.background.size > 0) {
75
+ await Promise.allSettled(Array.from(this.background));
76
+ }
77
+ }
78
+ async run(opts) {
79
+ const requestId = opts.requestId ?? cryptoRandomUUID();
80
+ // M1 (throw-parity): a non-string agentKey must degrade to pass-through,
81
+ // not throw before the customer's own call would have run.
82
+ const agentKey = normalizeAgentKey(typeof opts.agentKey === 'string' ? opts.agentKey : '');
83
+ // F10 kill switches: local config / env.
84
+ if (this.cfg.disabled || envDisabled()) {
85
+ return this.passThrough(opts, requestId, 'disabled');
86
+ }
87
+ // F1/F2/F3: unknown or malformed policy state → pass-through.
88
+ let policy;
89
+ try {
90
+ policy = this.cache.get(agentKey);
91
+ }
92
+ catch {
93
+ policy = undefined;
94
+ }
95
+ if (!policy || !isUsablePolicy(policy)) {
96
+ return this.passThrough(opts, requestId, 'pass_through');
97
+ }
98
+ if (policy.denied || !policy.enabled) {
99
+ return this.passThrough(opts, requestId, 'pass_through');
100
+ }
101
+ // F10: server-declared bad SDK version → pass-through.
102
+ if (policy.min_sdk_version && semverLt(SDK_VERSION, policy.min_sdk_version) !== false) {
103
+ return this.passThrough(opts, requestId, 'pass_through');
104
+ }
105
+ // F10: local circuit breaker.
106
+ if (this.breakerOpen(agentKey)) {
107
+ return this.passThrough(opts, requestId, 'pass_through');
108
+ }
109
+ switch (policy.mode) {
110
+ case 'shadow_sample':
111
+ return this.runShadowSample(opts, policy, requestId);
112
+ case 'cascade':
113
+ return this.runCascade(opts, policy, requestId);
114
+ case 'pass_through':
115
+ default: {
116
+ const result = await this.passThrough(opts, requestId, 'pass_through', policy.prod_model);
117
+ // Report-only decision emit — only when a policy row explicitly exists.
118
+ this.emitDecision(policy, requestId, {
119
+ mode: 'report-only',
120
+ propensity: 1.0,
121
+ action: { model: policy.prod_model },
122
+ candidate_set: [policy.prod_model],
123
+ });
124
+ return result;
125
+ }
126
+ }
127
+ }
128
+ // ── Modes ──
129
+ async passThrough(opts, requestId, mode, prodModel) {
130
+ // F6/F7: this call's errors are the customer's own — they propagate.
131
+ const result = await opts.callModel(prodModel ?? undefined, opts.input, {
132
+ requestId,
133
+ phase: 'primary',
134
+ });
135
+ return {
136
+ output: result.output,
137
+ servedModel: prodModel ?? undefined,
138
+ escalated: false,
139
+ requestId,
140
+ mode,
141
+ };
142
+ }
143
+ async runShadowSample(opts, policy, requestId) {
144
+ // Serve prod FIRST — shadow work never adds serving latency (§5.2).
145
+ const prodResult = await opts.callModel(policy.prod_model ?? undefined, opts.input, {
146
+ requestId,
147
+ phase: 'primary',
148
+ });
149
+ if (this.random() < (policy.sample_rate ?? 0)) {
150
+ this.spawnBackground(async () => {
151
+ // F9: any failure here skips the sample; serving already completed.
152
+ // M3: a hanging shadow call must not pin background work forever.
153
+ const candidateResult = await withTimeout(opts.callModel(policy.candidate_model, opts.input, {
154
+ requestId,
155
+ phase: 'shadow_candidate',
156
+ }), policy.max_extra_latency_ms ?? 15000, 'shadow-candidate');
157
+ const checkerCtx = { requestId, phase: 'checker' };
158
+ const [prodScore, candScore] = await Promise.all([
159
+ this.judgeOutput(opts, policy, prodResult.output, policy.prod_model ?? '', checkerCtx),
160
+ this.judgeOutput(opts, policy, candidateResult.output, policy.candidate_model ?? '', checkerCtx),
161
+ ]);
162
+ this.emitPair(policy, requestId, {
163
+ provenance: 'shadow_sample',
164
+ prod_cost_microcents: prodResult.costMicrocents ?? null,
165
+ candidate_cost_microcents: candidateResult.costMicrocents ?? null,
166
+ prod_quality: prodScore.score,
167
+ candidate_quality: candScore.score,
168
+ judge_model: prodScore.judgeModel,
169
+ gate_score: null,
170
+ });
171
+ this.emitQuality(policy, requestId, prodScore.score, prodScore.judgeModel);
172
+ });
173
+ }
174
+ this.emitDecision(policy, requestId, {
175
+ mode: 'shadow',
176
+ propensity: 1.0,
177
+ // L1: sample_rate travels in the action so diagnostics can reconstruct
178
+ // the sampling design from decision rows alone.
179
+ action: {
180
+ model: policy.prod_model,
181
+ shadow: policy.candidate_model,
182
+ sample_rate: policy.sample_rate,
183
+ },
184
+ candidate_set: [policy.prod_model, policy.candidate_model],
185
+ });
186
+ return {
187
+ output: prodResult.output,
188
+ servedModel: policy.prod_model ?? undefined,
189
+ escalated: false,
190
+ requestId,
191
+ mode: 'shadow_sample',
192
+ };
193
+ }
194
+ /**
195
+ * WS1 (Phase 3): the holdout-arm uniform draw.
196
+ *
197
+ * DEFAULT (`gate_config.randomization_unit` unset or `'request'`): a per-request
198
+ * Bernoulli via `this.random()` — BYTE-IDENTICAL to pre-Phase-3 behaviour. This
199
+ * is the live cascade path for every policy, so it must not change unless the
200
+ * policy explicitly opts in.
201
+ *
202
+ * TASK unit (`gate_config.randomization_unit === 'task'`): a DETERMINISTIC
203
+ * function of the current task id, so every request inside one `task()` lands on
204
+ * the same arm (whole-task treatment) → multi-request tasks are arm-consistent →
205
+ * task-level value evidence becomes causal (Phase 3 D10). Salted with
206
+ * `experiment_id` so a judge/experiment rotation re-randomizes assignments.
207
+ *
208
+ * Falls back to the per-request draw when there is no task context OR no
209
+ * `experiment_id` (M2 — never silently salt with a literal 'null'): those
210
+ * requests stay the Phase-2 non-actionable stratum, no throw, no regression.
211
+ */
212
+ holdoutUniform(policy) {
213
+ if (policy.gate_config?.randomization_unit === 'task') {
214
+ const taskId = currentTaskId();
215
+ const experimentId = policy.experiment_id;
216
+ if (taskId && experimentId) {
217
+ return hashToUnit(`${taskId}:${experimentId}`);
218
+ }
219
+ }
220
+ return this.random();
221
+ }
222
+ async runCascade(opts, policy, requestId) {
223
+ // Randomized holdout: control = forced pass-through (the experiment's
224
+ // control arm — the rollout IS the experiment, §3). WS1 (Phase 3): task-keyed
225
+ // when the policy opts in (holdoutUniform); byte-identical per-request draw
226
+ // otherwise. propensity stays `holdout_pct`/`1-holdout_pct` (logged metadata,
227
+ // NOT an IPW weight — the estimand uses raw arm means); a `holdout_pct=0`
228
+ // policy never draws control so propensity=0 is never emitted (decisions API
229
+ // rejects it). ctx (v0.2.1, #259) is preserved.
230
+ if (this.holdoutUniform(policy) < (policy.holdout_pct ?? 0)) {
231
+ const controlCtx = {
232
+ requestId,
233
+ phase: 'primary',
234
+ arm: 'control',
235
+ experimentId: policy.experiment_id ?? undefined,
236
+ };
237
+ const result = await opts.callModel(policy.prod_model ?? undefined, opts.input, controlCtx);
238
+ this.emitDecision(policy, requestId, {
239
+ mode: 'active',
240
+ propensity: policy.holdout_pct,
241
+ holdout_arm: 'control',
242
+ experiment_id: policy.experiment_id,
243
+ action: { model: policy.prod_model },
244
+ candidate_set: [policy.candidate_model, policy.prod_model],
245
+ });
246
+ // C2: control-arm outputs MUST be judged or the holdout test can never run.
247
+ this.spawnBackground(async () => {
248
+ const scored = await this.judgeOutput(opts, policy, result.output, undefined, {
249
+ requestId,
250
+ phase: 'checker',
251
+ arm: 'control',
252
+ experimentId: policy.experiment_id ?? undefined,
253
+ });
254
+ this.emitQuality(policy, requestId, scored.score, scored.judgeModel);
255
+ });
256
+ return {
257
+ output: result.output,
258
+ servedModel: policy.prod_model ?? undefined,
259
+ escalated: false,
260
+ arm: 'control',
261
+ experimentId: policy.experiment_id ?? undefined,
262
+ requestId,
263
+ mode: 'cascade',
264
+ };
265
+ }
266
+ // Treatment arm: candidate → gate → serve-or-escalate.
267
+ const agentKey = normalizeAgentKey(typeof opts.agentKey === 'string' ? opts.agentKey : String(opts.agentKey ?? ''));
268
+ const budgetMs = policy.max_extra_latency_ms ?? 15000;
269
+ // H4: ONE deadline bounds candidate + gate combined — the cap is the cap.
270
+ const deadline = this.now() + budgetMs;
271
+ // Treatment arm always carries the same arm/experiment tags.
272
+ const experimentId = policy.experiment_id ?? undefined;
273
+ let candidateResult = null;
274
+ let gateScore = null;
275
+ let escalationReason = null;
276
+ // M2: gate feasibility depends only on policy + opts — check it BEFORE
277
+ // paying for a candidate call that can never be served.
278
+ const checkerModel = checkerModelOf(policy);
279
+ if (!opts.checkerCaller || !checkerModel) {
280
+ escalationReason = 'gate:no_checker';
281
+ }
282
+ else {
283
+ try {
284
+ candidateResult = await withTimeout(opts.callModel(policy.candidate_model, opts.input, {
285
+ requestId,
286
+ phase: 'candidate',
287
+ arm: 'treatment',
288
+ experimentId,
289
+ }), budgetMs, 'candidate');
290
+ const validators = (policy.gate_config?.validators ?? undefined);
291
+ const g1 = runValidators(candidateResult.output, validators);
292
+ if (!g1.pass) {
293
+ escalationReason = g1.reason ?? 'g1';
294
+ }
295
+ else {
296
+ const remainingMs = deadline - this.now();
297
+ if (remainingMs <= 0) {
298
+ escalationReason = 'gate:budget_exhausted';
299
+ }
300
+ else {
301
+ gateScore = await runChecker({
302
+ checkerCaller: opts.checkerCaller,
303
+ checkerModel,
304
+ input: opts.input,
305
+ output: candidateResult.output,
306
+ rubric: rubricOf(policy),
307
+ timeoutMs: remainingMs,
308
+ ctx: { requestId, phase: 'checker', arm: 'treatment', experimentId },
309
+ });
310
+ if (gateScore < (policy.confidence_threshold ?? 1)) {
311
+ escalationReason = 'gate:below_threshold';
312
+ }
313
+ }
314
+ }
315
+ }
316
+ catch (err) {
317
+ // F4/F5: candidate or gate failure → escalate. Count toward the breaker.
318
+ this.recordCandidateError(agentKey);
319
+ escalationReason = `candidate_or_gate_error:${err instanceof Error ? err.name : 'unknown'}`;
320
+ }
321
+ }
322
+ if (candidateResult && escalationReason === null && gateScore !== null) {
323
+ // Serve the candidate.
324
+ this.recordEscalation(agentKey, false);
325
+ this.emitDecision(policy, requestId, {
326
+ mode: 'active',
327
+ propensity: 1 - policy.holdout_pct,
328
+ holdout_arm: 'treatment',
329
+ experiment_id: policy.experiment_id,
330
+ action: { model: policy.candidate_model, escalated: false, gate_score: gateScore },
331
+ candidate_set: [policy.candidate_model, policy.prod_model],
332
+ });
333
+ // H3: quality must be comparable across arms. With a custom judge, score
334
+ // the served output with THAT judge post-response (the gate score stays
335
+ // in the decision row); otherwise the rubric'd checker score IS the
336
+ // judge score — the same instrument the control arm uses.
337
+ if (opts.judge) {
338
+ const servedOutput = candidateResult.output;
339
+ const servedModel = policy.candidate_model ?? '';
340
+ this.spawnBackground(async () => {
341
+ const scored = await this.judgeOutput(opts, policy, servedOutput, servedModel, {
342
+ requestId,
343
+ phase: 'checker',
344
+ arm: 'treatment',
345
+ experimentId,
346
+ });
347
+ this.emitQuality(policy, requestId, scored.score, scored.judgeModel);
348
+ });
349
+ }
350
+ else {
351
+ this.emitQuality(policy, requestId, gateScore, checkerModel);
352
+ }
353
+ return {
354
+ output: candidateResult.output,
355
+ servedModel: policy.candidate_model ?? undefined,
356
+ escalated: false,
357
+ arm: 'treatment',
358
+ experimentId: policy.experiment_id ?? undefined,
359
+ requestId,
360
+ mode: 'cascade',
361
+ };
362
+ }
363
+ // Escalate to the production model. F6: THIS call's errors propagate —
364
+ // exactly what the customer's own call would have thrown.
365
+ this.recordEscalation(agentKey, true);
366
+ const prodResult = await opts.callModel(policy.prod_model ?? undefined, opts.input, {
367
+ requestId,
368
+ phase: 'escalation',
369
+ arm: 'treatment',
370
+ experimentId,
371
+ });
372
+ this.emitDecision(policy, requestId, {
373
+ mode: 'active',
374
+ propensity: 1 - policy.holdout_pct,
375
+ holdout_arm: 'treatment',
376
+ experiment_id: policy.experiment_id,
377
+ action: {
378
+ model: policy.prod_model,
379
+ escalated: true,
380
+ gate_score: gateScore,
381
+ esc_reason: escalationReason,
382
+ },
383
+ candidate_set: [policy.candidate_model, policy.prod_model],
384
+ });
385
+ // Escalation pair (biased pre-screen — provenance-labeled) + served quality (C2).
386
+ const candidateOutput = candidateResult;
387
+ this.spawnBackground(async () => {
388
+ const prodScored = await this.judgeOutput(opts, policy, prodResult.output, policy.prod_model ?? '', {
389
+ requestId,
390
+ phase: 'checker',
391
+ arm: 'treatment',
392
+ experimentId,
393
+ });
394
+ if (candidateOutput && gateScore !== null) {
395
+ this.emitPair(policy, requestId, {
396
+ provenance: 'escalation',
397
+ prod_cost_microcents: prodResult.costMicrocents ?? null,
398
+ candidate_cost_microcents: candidateOutput.costMicrocents ?? null,
399
+ prod_quality: prodScored.score,
400
+ candidate_quality: gateScore,
401
+ judge_model: prodScored.judgeModel,
402
+ gate_score: gateScore,
403
+ });
404
+ }
405
+ this.emitQuality(policy, requestId, prodScored.score, prodScored.judgeModel);
406
+ });
407
+ return {
408
+ output: prodResult.output,
409
+ servedModel: policy.prod_model ?? undefined,
410
+ escalated: true,
411
+ arm: 'treatment',
412
+ experimentId: policy.experiment_id ?? undefined,
413
+ requestId,
414
+ mode: 'cascade',
415
+ };
416
+ }
417
+ // ── Judging (client-side — text never leaves the process, §5.4) ──
418
+ async judgeOutput(opts, policy, output, producedByModel, checkerCtx) {
419
+ if (opts.judge) {
420
+ // M3: a hanging customer judge must not pin background work forever.
421
+ return withTimeout(opts.judge({
422
+ input: opts.input,
423
+ output,
424
+ model: producedByModel ?? policy.prod_model ?? '',
425
+ }), JUDGE_TIMEOUT_MS, 'judge');
426
+ }
427
+ const checkerModel = checkerModelOf(policy);
428
+ if (!opts.checkerCaller || !checkerModel) {
429
+ throw new Error('no judge available (checkerCaller + gate_config.checker_model required)');
430
+ }
431
+ // H3: same instrument + same rubric as the gate — arms stay comparable.
432
+ const score = await runChecker({
433
+ checkerCaller: opts.checkerCaller,
434
+ checkerModel,
435
+ input: opts.input,
436
+ output,
437
+ rubric: rubricOf(policy),
438
+ timeoutMs: JUDGE_TIMEOUT_MS,
439
+ ctx: checkerCtx,
440
+ });
441
+ return { score, judgeModel: checkerModel };
442
+ }
443
+ // ── Evidence emission (fire-and-forget, ≤2s, F8) ──
444
+ emitDecision(policy, requestId, fields) {
445
+ this.fireAndForget('/api/v1/decisions', {
446
+ decisions: [
447
+ {
448
+ request_id: requestId,
449
+ agent_key: policy.agent_key,
450
+ policy_id: `cascade-v1:${policy.id}`,
451
+ occurred_at: new Date(this.now()).toISOString(),
452
+ context_features: {
453
+ agent_key: policy.agent_key,
454
+ policy_version: policy.policy_version,
455
+ sdk_version: SDK_VERSION,
456
+ },
457
+ action: fields.action,
458
+ candidate_set: fields.candidate_set,
459
+ propensity: fields.propensity,
460
+ holdout_arm: fields.holdout_arm ?? null,
461
+ experiment_id: fields.experiment_id ?? null,
462
+ mode: fields.mode,
463
+ },
464
+ ],
465
+ });
466
+ }
467
+ emitPair(policy, requestId, fields) {
468
+ this.fireAndForget('/api/v1/paired-samples', {
469
+ pairs: [
470
+ {
471
+ request_id: requestId,
472
+ agent_key: policy.agent_key,
473
+ policy_id: policy.id,
474
+ policy_version: policy.policy_version,
475
+ prod_model: policy.prod_model,
476
+ candidate_model: policy.candidate_model,
477
+ occurred_at: new Date(this.now()).toISOString(),
478
+ ...fields,
479
+ },
480
+ ],
481
+ });
482
+ }
483
+ emitQuality(policy, requestId, score, judgeModel) {
484
+ this.fireAndForget('/api/v1/quality/metrics', {
485
+ metrics: [
486
+ {
487
+ agent_key: policy.agent_key,
488
+ metric_type: 'confidence_score',
489
+ metric_value: score,
490
+ request_id: requestId,
491
+ metadata: { source: 'metrx-routing-sdk', judge_model: judgeModel },
492
+ },
493
+ ],
494
+ });
495
+ }
496
+ fireAndForget(path, body) {
497
+ this.spawnBackground(async () => {
498
+ const controller = new AbortController();
499
+ const timer = setTimeout(() => controller.abort(), EMIT_TIMEOUT_MS);
500
+ try {
501
+ await this.fetchImpl(`${this.cfg.apiUrl}${path}`, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'Content-Type': 'application/json',
505
+ Authorization: `Bearer ${this.cfg.apiKey}`,
506
+ },
507
+ body: JSON.stringify(body),
508
+ signal: controller.signal,
509
+ });
510
+ }
511
+ finally {
512
+ clearTimeout(timer);
513
+ }
514
+ });
515
+ }
516
+ /** F8/F9: background work never throws into the request path. */
517
+ spawnBackground(fn) {
518
+ const p = fn().catch(() => undefined);
519
+ this.background.add(p);
520
+ p.finally(() => this.background.delete(p));
521
+ }
522
+ // ── Circuit breaker (client-side, supplemental — the authoritative breaker
523
+ // is the server-side ops cron auto-disable, §5.6 M5) ──
524
+ breakerFor(agentKey) {
525
+ let state = this.breakers.get(agentKey);
526
+ if (!state) {
527
+ // M5: bounded — an interpolated/per-request agentKey must not leak a
528
+ // Map entry per request in a long-lived process.
529
+ if (this.breakers.size >= 1000) {
530
+ return { errorTimestamps: [], escalationRing: [], cooldownUntil: 0 };
531
+ }
532
+ state = { errorTimestamps: [], escalationRing: [], cooldownUntil: 0 };
533
+ this.breakers.set(agentKey, state);
534
+ }
535
+ return state;
536
+ }
537
+ breakerOpen(agentKey) {
538
+ // Non-creating read (M5).
539
+ return (this.breakers.get(agentKey)?.cooldownUntil ?? 0) > this.now();
540
+ }
541
+ recordCandidateError(agentKey) {
542
+ const state = this.breakerFor(agentKey);
543
+ const nowMs = this.now();
544
+ state.errorTimestamps = state.errorTimestamps.filter((t) => nowMs - t < BREAKER_WINDOW_MS);
545
+ state.errorTimestamps.push(nowMs);
546
+ if (state.errorTimestamps.length >= BREAKER_ERROR_THRESHOLD) {
547
+ state.cooldownUntil = nowMs + BREAKER_COOLDOWN_MS;
548
+ state.errorTimestamps = [];
549
+ }
550
+ }
551
+ recordEscalation(agentKey, escalated) {
552
+ const state = this.breakerFor(agentKey);
553
+ state.escalationRing.push(escalated);
554
+ if (state.escalationRing.length > BREAKER_ESCALATION_WINDOW)
555
+ state.escalationRing.shift();
556
+ if (state.escalationRing.length === BREAKER_ESCALATION_WINDOW) {
557
+ const rate = state.escalationRing.filter(Boolean).length / BREAKER_ESCALATION_WINDOW;
558
+ if (rate >= BREAKER_ESCALATION_RATE) {
559
+ // Candidate is useless here — stop paying its latency (§7 break-even).
560
+ state.cooldownUntil = this.now() + BREAKER_COOLDOWN_MS;
561
+ state.escalationRing = [];
562
+ }
563
+ }
564
+ }
565
+ // ── Policy fetch ──
566
+ makePolicyFetcher() {
567
+ return async (etag) => {
568
+ const headers = {
569
+ Authorization: `Bearer ${this.cfg.apiKey}`,
570
+ };
571
+ // H1: the ETag embeds the org id — the SDK cannot reconstruct it. Echo
572
+ // the server's header back OPAQUELY or revalidation can never 304.
573
+ if (etag !== null)
574
+ headers['If-None-Match'] = etag;
575
+ const res = await this.fetchImpl(`${this.cfg.apiUrl}/api/v1/routing/policies`, {
576
+ method: 'GET',
577
+ headers,
578
+ });
579
+ if (res.status === 304)
580
+ return { policies: [], version: 0, notModified: true };
581
+ if (!res.ok)
582
+ throw new Error(`policy fetch failed: ${res.status}`);
583
+ const body = (await res.json());
584
+ return {
585
+ policies: body.policies ?? [],
586
+ version: body.version ?? 0,
587
+ etag: res.headers.get('ETag'),
588
+ };
589
+ };
590
+ }
591
+ }
592
+ // ── Convenience wrapper: one router per client instance ──
593
+ const routers = new WeakMap();
594
+ export function routerFor(client, overrides) {
595
+ let router = routers.get(client);
596
+ if (!router) {
597
+ const creds = client.getRoutingCredentials();
598
+ router = new MetrxRouter({ apiKey: creds.apiKey, apiUrl: creds.apiUrl, ...overrides });
599
+ routers.set(client, router);
600
+ }
601
+ return router;
602
+ }
603
+ /**
604
+ * Run one request under the agent's routing policy. Day 0 (no policy rows):
605
+ * behaves exactly like `opts.callModel(undefined, opts.input)`.
606
+ */
607
+ export async function runWithPolicy(client, opts) {
608
+ return routerFor(client).run(opts);
609
+ }
610
+ // ── Helpers ──
611
+ function inRange(v, lo, hi) {
612
+ return typeof v === 'number' && !isNaN(v) && v >= lo && v <= hi;
613
+ }
614
+ function isUsablePolicy(p) {
615
+ if (typeof p !== 'object' || p === null)
616
+ return false;
617
+ if (!['pass_through', 'shadow_sample', 'cascade'].includes(p.mode))
618
+ return false;
619
+ if (p.mode !== 'pass_through') {
620
+ // H2: a shadow/cascade policy without a prod_model would serve fine but
621
+ // every emitted pair is rejected server-side — a silent dead experiment.
622
+ if (typeof p.candidate_model !== 'string' || !p.candidate_model)
623
+ return false;
624
+ if (typeof p.prod_model !== 'string' || !p.prod_model)
625
+ return false;
626
+ // M4 (F3 fully applied): server-controlled numbers must be sane or the
627
+ // whole row is unusable → pass-through.
628
+ if (!inRange(p.sample_rate, 0, 1))
629
+ return false;
630
+ if (!inRange(p.holdout_pct, 0, 0.5))
631
+ return false;
632
+ if (typeof p.max_extra_latency_ms !== 'number' || p.max_extra_latency_ms <= 0)
633
+ return false;
634
+ if (p.mode === 'cascade' && !inRange(p.confidence_threshold, 0, 1))
635
+ return false;
636
+ }
637
+ return true;
638
+ }
639
+ function rubricOf(policy) {
640
+ return typeof policy.gate_config?.rubric === 'string'
641
+ ? policy.gate_config.rubric
642
+ : undefined;
643
+ }
644
+ function checkerModelOf(policy) {
645
+ const m = policy.gate_config?.checker_model;
646
+ return typeof m === 'string' ? m : null;
647
+ }
648
+ function envDisabled() {
649
+ try {
650
+ return typeof process !== 'undefined' && process?.env?.METRX_ROUTING_DISABLED === 'true';
651
+ }
652
+ catch {
653
+ return false;
654
+ }
655
+ }
656
+ /**
657
+ * Loose semver-ish comparison. Returns true when a < b, false when a >= b,
658
+ * and `undefined` when either side is unparseable — the caller treats
659
+ * "unknown" as pass-through (fail toward doing nothing).
660
+ */
661
+ export function semverLt(a, b) {
662
+ const pa = a.split('.').map(Number);
663
+ const pb = b.split('.').map(Number);
664
+ if (pa.some(isNaN) || pb.some(isNaN) || pa.length === 0 || pb.length === 0)
665
+ return undefined;
666
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
667
+ const x = pa[i] ?? 0;
668
+ const y = pb[i] ?? 0;
669
+ if (x < y)
670
+ return true;
671
+ if (x > y)
672
+ return false;
673
+ }
674
+ return false;
675
+ }
676
+ function cryptoRandomUUID() {
677
+ try {
678
+ return crypto.randomUUID();
679
+ }
680
+ catch {
681
+ // Extremely defensive: ancient runtimes without webcrypto.
682
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
683
+ }
684
+ }
685
+ //# sourceMappingURL=routing.js.map