auto-model-router 0.1.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 (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Complexity classification: a weighted heuristic over Features, with an
3
+ * optional LLM adjudicator for turns the heuristic cannot call confidently.
4
+ */
5
+
6
+ import type { CatalogSource } from "../catalog/types.ts";
7
+ import type { QualityAxis, RouterConfig } from "../config/types.ts";
8
+ import { forecast } from "../cost/forecast.ts";
9
+ import type { Ledger } from "../cost/types.ts";
10
+ import { estimateTokens } from "../tokens/estimate.ts";
11
+ import type { UpstreamClient } from "../upstream/types.ts";
12
+ import { sha256Hex } from "../util/hash.ts";
13
+ import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
14
+ import type { Classification, Features, TaskType, Tier } from "./types.ts";
15
+
16
+ /**
17
+ * Heuristic scorer: a weighted linear sum over Features, clamped to 0-1 and
18
+ * bucketed into tiers. The weights encode two economic facts:
19
+ *
20
+ * - A tool-result continuation is the cheapest kind of turn in agent traffic
21
+ * (read tool output, take the next mechanical step) and the largest cost
22
+ * lever, so it carries the dominant NEGATIVE weight.
23
+ * - Failure signals cost money twice: a cheap model that flounders gets
24
+ * escalated and the turn is paid for twice. So a failed tool result, a
25
+ * repeated tool call, complexity keywords, and an explicit reasoning
26
+ * request carry the dominant POSITIVE weights.
27
+ */
28
+ const BASE = 0.3;
29
+ const W_TOOL_CONTINUATION = -0.28; // dominant negative
30
+ const W_COMPLEXITY_KEYWORD = 0.1;
31
+ const CAP_COMPLEXITY = 0.3;
32
+ const W_TRIVIALITY_KEYWORD = -0.09;
33
+ const CAP_TRIVIALITY = -0.27;
34
+ const W_TOOL_FAILED = 0.26; // dominant positive: retry loops are expensive
35
+ const W_REPEATED_CALL = 0.14; // the model is stuck
36
+ const W_TERSE = -0.1;
37
+ const W_CODE_BLOCK = 0.04;
38
+ const CAP_CODE = 0.08;
39
+ const W_DIFF = 0.05;
40
+ const W_QUESTION = 0.03;
41
+ const CAP_QUESTION = 0.06;
42
+ const W_LARGE_CONTENT = 0.05;
43
+ const W_HUGE_CONTENT = 0.1;
44
+ const W_TURN_DEPTH = 0.004; // long conversations accumulate entangled context
45
+ const CAP_TURN_DEPTH = 0.08;
46
+ const W_LOOP_DEPTH = -0.008; // deep loops are mechanical
47
+ const CAP_LOOP_DEPTH = -0.06;
48
+ const W_AUTONOMOUS_LOOP = 0.06; // but a loop this long implies a substantial underlying task
49
+ const W_IMAGES = 0.04;
50
+ const W_TOOLS_OFFERED = 0.03;
51
+
52
+ /** Score bucket boundaries: [trivial, simple, moderate, hard]. */
53
+ const BOUNDARIES: readonly [number, number, number] = [0.25, 0.5, 0.75];
54
+
55
+ /** A client that asks for reasoning is stating expected difficulty directly. */
56
+ function reasoningWeight(level: ReasoningLevel | undefined): number {
57
+ switch (level) {
58
+ case "medium":
59
+ return 0.14;
60
+ case "high":
61
+ return 0.24;
62
+ case "xhigh":
63
+ return 0.3;
64
+ case "max":
65
+ return 0.34;
66
+ default:
67
+ // off/minimal/low/undefined: no stated difficulty above the baseline.
68
+ return 0;
69
+ }
70
+ }
71
+
72
+ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
73
+ const reasons: string[] = [];
74
+ let score = BASE;
75
+ const add = (delta: number, why: string): void => {
76
+ if (delta === 0) return;
77
+ score += delta;
78
+ reasons.push(`${delta > 0 ? "+" : ""}${delta.toFixed(2)} ${why}`);
79
+ };
80
+
81
+ if (f.isToolResultContinuation) add(W_TOOL_CONTINUATION, "tool-result continuation (mechanical next step)");
82
+ add(
83
+ Math.min(f.complexityKeywords.length * W_COMPLEXITY_KEYWORD, CAP_COMPLEXITY),
84
+ `complexity keywords [${f.complexityKeywords.join(", ")}]`,
85
+ );
86
+ add(
87
+ Math.max(f.trivialityKeywords.length * W_TRIVIALITY_KEYWORD, CAP_TRIVIALITY),
88
+ `triviality keywords [${f.trivialityKeywords.join(", ")}]`,
89
+ );
90
+ if (f.lastToolFailed) add(W_TOOL_FAILED, "last tool result failed");
91
+ if (f.repeatedToolCall) add(W_REPEATED_CALL, "repeated tool call (model is stuck)");
92
+ const rw = reasoningWeight(f.requestedReasoning);
93
+ if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
94
+ if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
95
+ add(Math.min(f.codeBlocks * W_CODE_BLOCK, CAP_CODE), `${f.codeBlocks} code block(s) in new content`);
96
+ if (f.looksLikeDiff) add(W_DIFF, "diff in new content");
97
+ add(Math.min(f.questionCount * W_QUESTION, CAP_QUESTION), `${f.questionCount} question(s)`);
98
+ if (f.newContentTokens > 8000) add(W_HUGE_CONTENT, "very large new content");
99
+ else if (f.newContentTokens > 2000) add(W_LARGE_CONTENT, "large new content");
100
+ add(Math.min(f.turnDepth * W_TURN_DEPTH, CAP_TURN_DEPTH), `conversation depth ${f.turnDepth}`);
101
+ add(Math.max(f.toolLoopDepth * W_LOOP_DEPTH, CAP_LOOP_DEPTH), `tool loop depth ${f.toolLoopDepth}`);
102
+ if (f.toolLoopDepth >= cfg.classifier.agenticLoopDepth && f.toolLoopDepth > 0) {
103
+ add(W_AUTONOMOUS_LOOP, "long autonomous loop implies a substantial task");
104
+ }
105
+ if (f.hasImages) add(W_IMAGES, "image input");
106
+ if (f.toolCount > 0) add(W_TOOLS_OFFERED, `${f.toolCount} tools offered`);
107
+
108
+ score = Math.min(1, Math.max(0, score));
109
+ const tier: Tier = score < BOUNDARIES[0] ? "trivial" : score < BOUNDARIES[1] ? "simple" : score < BOUNDARIES[2] ? "moderate" : "hard";
110
+
111
+ // Near a bucket boundary the heuristic is guessing; confidence is the
112
+ // distance to the nearest boundary, scaled so half a bucket of clearance
113
+ // reads as fully confident. Low confidence ⇒ eligible for LLM adjudication.
114
+ let dist = 1;
115
+ for (const b of BOUNDARIES) dist = Math.min(dist, Math.abs(score - b));
116
+ const confidence = Math.min(1, dist / 0.125);
117
+
118
+ return { tier, task: classifyTask(f), confidence, source: "heuristic", reasons, score };
119
+ }
120
+
121
+ /** Quality axis rule: deep loops → agentic; tools offered → toolAxis; chat → chatAxis. */
122
+ export function pickQualityAxis(f: Features, cfg: RouterConfig): QualityAxis {
123
+ if (f.toolLoopDepth >= cfg.classifier.agenticLoopDepth) return "agentic";
124
+ if (f.toolCount > 0) return cfg.classifier.toolAxis;
125
+ return cfg.classifier.chatAxis;
126
+ }
127
+
128
+ /**
129
+ * Task-type classification: the KIND of work, orthogonal to complexity tier.
130
+ * Cheap and deterministic — no tokenizer, no model call. Vision is the only
131
+ * hard signal (image input); the rest are keyword/structural heuristics over
132
+ * the newest user content. The task selects the quality axis and capability
133
+ * filters; the tier still bounds cost.
134
+ */
135
+ export function classifyTask(f: Features): TaskType {
136
+ if (f.hasImages) return "vision";
137
+ // Coding: code blocks, diffs, a tool loop, or tools offered — agent tool use
138
+ // is coding work. Bare chat (no tools, no code) falls through.
139
+ if (f.codeBlocks > 0 || f.looksLikeDiff || f.toolLoopDepth > 0 || f.toolCount > 0) return "coding";
140
+ // Data: tabular/structured analysis language.
141
+ if (f.complexityKeywords.some((k) => k === "optimize" || k === "migrate")) return "data";
142
+ // Documentation: prose-heavy, explanatory language, no code.
143
+ if (f.complexityKeywords.some((k) => k === "design" || k === "architecture")) return "documentation";
144
+ return "chat";
145
+ }
146
+
147
+ export interface ClassifyDeps {
148
+ upstream: UpstreamClient;
149
+ ledger: Ledger | null;
150
+ catalog: CatalogSource | null;
151
+ }
152
+
153
+ const ADJUDICATOR_SYSTEM = [
154
+ "You classify one turn of a coding-agent conversation into a complexity tier.",
155
+ "trivial: mechanical step, rename, typo, formatting, reading a successful tool result.",
156
+ "simple: small localized change, single-file edit, straightforward question.",
157
+ "moderate: multi-file change, debugging, non-obvious design decision.",
158
+ "hard: architecture, subtle concurrency or correctness bug, security, large refactor.",
159
+ "Reply with exactly one word: trivial, simple, moderate, or hard.",
160
+ ].join("\n");
161
+
162
+ // The adjudicator answers with one token; a small completion budget is plenty.
163
+ const ADJUDICATOR_COMPLETION_TOKENS = 4;
164
+ // Rough completion size of the turn being classified, for the cost-fraction guard.
165
+ const EST_TURN_COMPLETION_TOKENS = 1024;
166
+ // Hard cap on the user-content excerpt sent to the adjudicator.
167
+ const DIGEST_EXCERPT_CHARS = 400;
168
+
169
+ /**
170
+ * Verdict caches are keyed by the config object so separate routers (and
171
+ * separate tests) never share verdicts. Bounded LRU per config.
172
+ */
173
+ const verdictCaches = new WeakMap<RouterConfig, Map<string, Tier>>();
174
+
175
+ function verdictCache(cfg: RouterConfig): Map<string, Tier> {
176
+ let cache = verdictCaches.get(cfg);
177
+ if (cache === undefined) {
178
+ cache = new Map();
179
+ verdictCaches.set(cfg, cache);
180
+ }
181
+ return cache;
182
+ }
183
+
184
+ function lruGet(cache: Map<string, Tier>, key: string): Tier | undefined {
185
+ const hit = cache.get(key);
186
+ if (hit !== undefined) {
187
+ // Refresh recency: Map preserves insertion order.
188
+ cache.delete(key);
189
+ cache.set(key, hit);
190
+ }
191
+ return hit;
192
+ }
193
+
194
+ function lruSet(cache: Map<string, Tier>, key: string, value: Tier, maxSize: number): void {
195
+ cache.delete(key);
196
+ cache.set(key, value);
197
+ while (cache.size > maxSize) {
198
+ const oldest = cache.keys().next();
199
+ if (oldest.done) break;
200
+ cache.delete(oldest.value);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Compact digest of the request for the adjudicator. Never the whole
206
+ * conversation: shipping full history to the judge would cost more than the
207
+ * turn it is classifying.
208
+ */
209
+ function buildDigest(req: NormRequest, f: Features): string {
210
+ const toolNames = req.tools.length === 0 ? "none" : req.tools.map((t) => t.name).slice(0, 24).join(", ");
211
+ let excerpt = "(no user content yet)";
212
+ for (let i = req.messages.length - 1; i >= 0; i--) {
213
+ const m = req.messages[i];
214
+ if (m?.role === "user") {
215
+ excerpt = m.text.length > DIGEST_EXCERPT_CHARS ? `${m.text.slice(0, DIGEST_EXCERPT_CHARS)}...` : m.text;
216
+ break;
217
+ }
218
+ }
219
+ return [
220
+ `tools_offered: ${toolNames}`,
221
+ `tool_result_continuation: ${f.isToolResultContinuation}`,
222
+ `tool_loop_depth: ${f.toolLoopDepth}`,
223
+ `last_tool_failed: ${f.lastToolFailed}`,
224
+ `prompt_tokens_estimate: ${f.promptTokens}`,
225
+ `newest_user_content: """${excerpt}"""`,
226
+ ].join("\n");
227
+ }
228
+
229
+ export async function classify(
230
+ req: NormRequest,
231
+ f: Features,
232
+ cfg: RouterConfig,
233
+ deps: ClassifyDeps,
234
+ ): Promise<Classification> {
235
+ const heuristic = scoreHeuristic(f, cfg);
236
+ const cc = cfg.classifier;
237
+ if (cc.ambiguityThreshold <= 0 || heuristic.confidence >= cc.ambiguityThreshold) return heuristic;
238
+
239
+ const digest = buildDigest(req, f);
240
+ const cache = verdictCache(cfg);
241
+ const fingerprint = sha256Hex(digest);
242
+ const cached = lruGet(cache, fingerprint);
243
+ if (cached !== undefined) {
244
+ return {
245
+ tier: cached,
246
+ task: heuristic.task,
247
+ confidence: 0.9,
248
+ source: "llm",
249
+ score: heuristic.score,
250
+ reasons: [...heuristic.reasons, `adjudicator: ${cached} (cached verdict)`],
251
+ };
252
+ }
253
+
254
+ // Cost guard: adjudication must be cheap relative to the turn it classifies.
255
+ const blend = deps.ledger?.blendedRate(cfg.ledger.blendWindowDays) ?? null;
256
+ const inputRate = (blend?.inputPerMtok ?? cfg.ledger.fallbackBlend.inputPerMtok) / 1e6;
257
+ const outputRate = (blend?.outputPerMtok ?? cfg.ledger.fallbackBlend.outputPerMtok) / 1e6;
258
+ const judge = deps.catalog?.find(cc.model);
259
+ const digestTokens = estimateTokens(Buffer.byteLength(ADJUDICATOR_SYSTEM) + Buffer.byteLength(digest), "unknown", deps.ledger);
260
+ const adjudicatorUsd =
261
+ judge !== undefined
262
+ ? forecast(judge, {
263
+ promptTokens: digestTokens,
264
+ completionTokens: ADJUDICATOR_COMPLETION_TOKENS,
265
+ cacheHitRate: 0,
266
+ images: 0,
267
+ }).expectedUsd
268
+ : // Judge slug missing from the catalog: price it at the blend so the guard still bites.
269
+ digestTokens * inputRate + ADJUDICATOR_COMPLETION_TOKENS * outputRate;
270
+ const pendingTurnUsd = f.promptTokens * inputRate + EST_TURN_COMPLETION_TOKENS * outputRate;
271
+ if (adjudicatorUsd > cc.maxCostUsd || adjudicatorUsd > pendingTurnUsd * cc.maxCostFraction) {
272
+ return {
273
+ ...heuristic,
274
+ reasons: [...heuristic.reasons, `adjudicator skipped: est $${adjudicatorUsd.toFixed(6)} breaches cost guard`],
275
+ };
276
+ }
277
+
278
+ const controller = new AbortController();
279
+ let timer: ReturnType<typeof setTimeout> | undefined;
280
+ const timeout = new Promise<never>((_resolve, reject) => {
281
+ // Reject even if the upstream implementation ignores the abort signal.
282
+ timer = setTimeout(() => {
283
+ controller.abort();
284
+ reject(new Error(`timed out after ${cc.timeoutMs}ms`));
285
+ }, cc.timeoutMs);
286
+ });
287
+ try {
288
+ const { text } = await Promise.race([
289
+ deps.upstream.complete(
290
+ {
291
+ model: cc.model,
292
+ stream: false,
293
+ max_tokens: ADJUDICATOR_COMPLETION_TOKENS,
294
+ temperature: 0,
295
+ messages: [
296
+ { role: "system", content: ADJUDICATOR_SYSTEM },
297
+ { role: "user", content: digest },
298
+ ],
299
+ },
300
+ controller.signal,
301
+ ),
302
+ timeout,
303
+ ]);
304
+ const word = text.trim().toLowerCase();
305
+ if (word === "trivial" || word === "simple" || word === "moderate" || word === "hard") {
306
+ lruSet(cache, fingerprint, word, cc.cacheSize);
307
+ return {
308
+ tier: word,
309
+ task: heuristic.task,
310
+ confidence: 0.9,
311
+ source: "llm",
312
+ score: heuristic.score,
313
+ reasons: [...heuristic.reasons, `adjudicator: ${word}`],
314
+ };
315
+ }
316
+ return {
317
+ ...heuristic,
318
+ reasons: [...heuristic.reasons, `adjudicator reply not a tier word; kept heuristic ${heuristic.tier}`],
319
+ };
320
+ } catch (err) {
321
+ const why = err instanceof Error ? err.message : String(err);
322
+ return {
323
+ ...heuristic,
324
+ reasons: [...heuristic.reasons, `adjudicator failed (${why}); kept heuristic ${heuristic.tier}`],
325
+ };
326
+ } finally {
327
+ clearTimeout(timer);
328
+ }
329
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Guarded-probe state machine for mid-stream escalation.
3
+ *
4
+ * While undecided, the probe holds every chunk. It commits (release the
5
+ * buffer to the client) once the generation proves itself — enough text, or
6
+ * complete valid tool-call JSON — and escalates when it observes a configured
7
+ * failure signal. Pure and synchronous: the only impurity is an injectable
8
+ * clock for the hold-time ceiling.
9
+ */
10
+
11
+ import type { EscalationSignal, ProbePlan, ProbeVerdict } from "./types.ts";
12
+ import type { FinishReason, NormRequest, NormToolCall, UpstreamChunk } from "../wire/types.ts";
13
+
14
+ export interface Probe {
15
+ /** Returns a verdict once decided, null while still buffering. */
16
+ observe(chunk: UpstreamChunk): ProbeVerdict | null;
17
+ /** Stream ended while still undecided; decide from what was held. */
18
+ verdictOnEnd(): ProbeVerdict;
19
+ /** Live view of the buffer; do not mutate. Valid until the next observe(). */
20
+ held(): UpstreamChunk[];
21
+ }
22
+
23
+ // Refusal openers, matched against the start of the held text. Kept
24
+ // deliberately tight and anchored: a false positive pays for two generations.
25
+ const REFUSAL_OPENERS: readonly RegExp[] = [
26
+ // "I'm sorry, but I can't ..." / "I am sorry I cannot ..."
27
+ /^(?:i'?m|i am) sorry,? (?:but )?i (?:can'?t|cannot|won'?t|will not)/i,
28
+ // "I cannot assist/help/comply/fulfill/provide ..."
29
+ /^i (?:can'?t|cannot) (?:assist|help|comply|fulfill|provide|generate|create)/i,
30
+ // "I'm unable to help ..." / "I'm not able to provide ..."
31
+ /^i'?m (?:not able|unable) to (?:assist|help|comply|fulfill|provide)/i,
32
+ // "I must decline/refuse ..."
33
+ /^i must (?:decline|refuse)/i,
34
+ // "Sorry, I can't ..."
35
+ /^(?:sorry|apologies),? (?:but )?i (?:can'?t|cannot)/i,
36
+ ];
37
+
38
+ const INVALID = Symbol("invalid-json");
39
+
40
+ function parseJson(text: string): unknown {
41
+ try {
42
+ return JSON.parse(text);
43
+ } catch {
44
+ return INVALID;
45
+ }
46
+ }
47
+
48
+ // Key-order-insensitive comparison: the same call re-emitted with reordered
49
+ // keys is still a loop. Parsed JSON contains no undefined/functions, so plain
50
+ // per-node stringification is exact.
51
+ function stableStringify(v: unknown): string {
52
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
53
+ if (v !== null && typeof v === "object") {
54
+ const entries = Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
55
+ return `{${entries.map(([k, val]) => `${JSON.stringify(k)}:${stableStringify(val)}`).join(",")}}`;
56
+ }
57
+ return JSON.stringify(v);
58
+ }
59
+
60
+ interface ToolCallAcc {
61
+ name: string | null;
62
+ args: string;
63
+ sawArgs: boolean;
64
+ }
65
+
66
+ export function createProbe(
67
+ plan: ProbePlan,
68
+ req: NormRequest,
69
+ triggers: ReadonlySet<string>,
70
+ now: () => number = Date.now,
71
+ ): Probe {
72
+ const heldChunks: UpstreamChunk[] = [];
73
+ const toolCalls = new Map<number, ToolCallAcc>();
74
+ const startedAt = now();
75
+ let text = "";
76
+ // Cheap 4-chars-per-token approximation. It only gates WHEN we stop holding
77
+ // (latency), never money — the ledger uses reported usage.
78
+ let approxTextTokens = 0;
79
+ let finishReason: FinishReason | null = null;
80
+ // Reasoning deltas prove the model is working even before any content
81
+ // arrives. Used only by the hold-time ceiling, never by the end verdict:
82
+ // a stream that ENDS having emitted only reasoning really is hollow.
83
+ let sawReasoning = false;
84
+ let decided: ProbeVerdict | null = null;
85
+
86
+ const commit = (reason: string): ProbeVerdict => (decided = { action: "commit", reason });
87
+ const escalate = (signal: EscalationSignal, reason: string): ProbeVerdict =>
88
+ (decided = { action: "escalate", signal, reason });
89
+
90
+ const matchesRefusal = (): boolean => {
91
+ const head = text.trimStart();
92
+ return head !== "" && REFUSAL_OPENERS.some((re) => re.test(head));
93
+ };
94
+
95
+ const lastAssistantToolCall = (): NormToolCall | null => {
96
+ for (let i = req.messages.length - 1; i >= 0; i--) {
97
+ const m = req.messages[i];
98
+ if (m && m.role === "assistant" && m.toolCalls.length > 0) {
99
+ return m.toolCalls[m.toolCalls.length - 1] ?? null;
100
+ }
101
+ }
102
+ return null;
103
+ };
104
+
105
+ const isRepeat = (name: string | null, argsParsed: unknown): boolean => {
106
+ if (!triggers.has("repeat_tool_call") || name === null) return false;
107
+ const prev = lastAssistantToolCall();
108
+ if (!prev || prev.name !== name) return false;
109
+ const prevParsed = parseJson(prev.argsJson);
110
+ if (prevParsed === INVALID) return false;
111
+ return stableStringify(prevParsed) === stableStringify(argsParsed);
112
+ };
113
+
114
+ // End-of-stream evaluation, shared by the finish event and verdictOnEnd.
115
+ const endVerdict = (): ProbeVerdict => {
116
+ if (toolCalls.size > 0) {
117
+ let sawValid = false;
118
+ for (const acc of toolCalls.values()) {
119
+ // Functions without parameters stream no argument fragments;
120
+ // absent arguments mean an empty object, not malformed JSON.
121
+ const parsed = parseJson(acc.args === "" ? "{}" : acc.args);
122
+ if (parsed === INVALID) continue;
123
+ sawValid = true;
124
+ if (isRepeat(acc.name, parsed)) {
125
+ return escalate("repeat_tool_call", `tool call "${acc.name ?? "?"}" repeats the previous assistant call`);
126
+ }
127
+ }
128
+ if (!sawValid) {
129
+ if (triggers.has("malformed_tool_args")) {
130
+ return escalate("malformed_tool_args", "tool-call arguments are not complete valid JSON");
131
+ }
132
+ if (finishReason === "length" && triggers.has("length_stop")) {
133
+ return escalate("length_stop", "hit the length cap mid tool-call arguments");
134
+ }
135
+ }
136
+ return commit(sawValid ? "tool call complete" : "tool call unvalidated; its signals are disabled");
137
+ }
138
+
139
+ const hasText = text.trim() !== "";
140
+ if (finishReason === "length") {
141
+ // A length finish on PROSE is the client's own `max_tokens` doing its
142
+ // job, not a model failure — and escalating cannot fix it, because the
143
+ // retry runs under the same cap and truncates in the same place. All it
144
+ // buys is paying twice, at a dearer tier, for the same truncation. The
145
+ // caller's remedy is a larger cap or a continuation, not a better model.
146
+ //
147
+ // A length finish that truncated TOOL-CALL ARGUMENTS is different and
148
+ // still escalates (above): that output is structurally unusable, and a
149
+ // different model may emit a well-formed call before the cap.
150
+ if (!hasText) {
151
+ if (triggers.has("empty_completion")) {
152
+ return escalate("empty_completion", "hit the length cap having produced nothing");
153
+ }
154
+ return commit("length finish with no content tolerated; signal disabled");
155
+ }
156
+ return commit("length finish on prose: the client's max_tokens, not a model failure");
157
+ }
158
+ if (req.forcedToolChoice && req.tools.length > 0 && triggers.has("missing_expected_tool_call")) {
159
+ return escalate("missing_expected_tool_call", "tool choice was forced but the model produced prose");
160
+ }
161
+ if (!hasText) {
162
+ if (triggers.has("empty_completion")) return escalate("empty_completion", "finished with no content");
163
+ return commit("empty completion tolerated; signal disabled");
164
+ }
165
+ if (matchesRefusal() && triggers.has("refusal")) {
166
+ return escalate("refusal", "completion opens with a refusal");
167
+ }
168
+ if (finishReason === "error" && triggers.has("upstream_error")) {
169
+ return escalate("upstream_error", "upstream reported an error finish");
170
+ }
171
+ return commit("completed with content");
172
+ };
173
+
174
+ return {
175
+ observe(chunk: UpstreamChunk): ProbeVerdict | null {
176
+ if (decided) return decided;
177
+ heldChunks.push(chunk);
178
+ if (!plan.enabled) return commit("probe disabled");
179
+
180
+ for (const ev of chunk.events) {
181
+ switch (ev.type) {
182
+ case "text":
183
+ text += ev.delta;
184
+ approxTextTokens += Math.ceil(ev.delta.length / 4);
185
+ break;
186
+ case "reasoning":
187
+ sawReasoning = true;
188
+ break;
189
+ case "tool_call": {
190
+ let acc = toolCalls.get(ev.index);
191
+ if (!acc) {
192
+ acc = { name: null, args: "", sawArgs: false };
193
+ toolCalls.set(ev.index, acc);
194
+ }
195
+ if (ev.name !== undefined) acc.name = ev.name;
196
+ if (ev.argsDelta !== undefined) {
197
+ acc.args += ev.argsDelta;
198
+ acc.sawArgs = true;
199
+ }
200
+ break;
201
+ }
202
+ case "finish":
203
+ finishReason = ev.reason;
204
+ break;
205
+ default:
206
+ break;
207
+ }
208
+ }
209
+
210
+ // Refusal patterns are anchored openers, so they are checkable the
211
+ // moment text starts arriving — do not wait for the finish event.
212
+ if (triggers.has("refusal") && text !== "" && matchesRefusal()) {
213
+ return escalate("refusal", "held text opens with a refusal");
214
+ }
215
+ if (finishReason !== null) return endVerdict();
216
+ if (plan.maxTokens > 0 && approxTextTokens >= plan.maxTokens) {
217
+ return commit(`held ~${approxTextTokens} text tokens, at the probe budget`);
218
+ }
219
+ for (const acc of toolCalls.values()) {
220
+ if (!acc.sawArgs || acc.args === "") continue;
221
+ const parsed = parseJson(acc.args);
222
+ if (parsed === INVALID) continue;
223
+ // A loop repeats too: check identity against the previous call
224
+ // before letting a structurally valid call commit.
225
+ if (isRepeat(acc.name, parsed)) {
226
+ return escalate("repeat_tool_call", `tool call "${acc.name ?? "?"}" repeats the previous assistant call`);
227
+ }
228
+ return commit(`tool call "${acc.name ?? "?"}" arguments are complete valid JSON`);
229
+ }
230
+ if (plan.maxHoldMs > 0 && now() - startedAt >= plan.maxHoldMs) {
231
+ // The ceiling bounds how long we withhold output; the stream is
232
+ // still OPEN here, so "nothing useful yet" means slow, not broken.
233
+ //
234
+ // Committing unconditionally (the original behaviour) blessed a
235
+ // genuinely stalled stream as served. Running the full end-of-stream
236
+ // verdict instead over-corrects the other way: a reasoning model
237
+ // that has emitted only reasoning tokens after 8s is working
238
+ // normally, and escalating throws away a paid, healthy generation
239
+ // to re-run it dearer.
240
+ //
241
+ // So the ceiling escalates only on the absence of ANY sign of life.
242
+ // Real end-of-stream hollowness is still caught by `verdictOnEnd`,
243
+ // where an ended stream that produced only reasoning IS hollow.
244
+ const aliveButSlow = text !== "" || sawReasoning || toolCalls.size > 0;
245
+ if (aliveButSlow) return commit("hold ceiling reached while still generating");
246
+ if (triggers.has("empty_completion")) {
247
+ return escalate("empty_completion", "hold ceiling reached with no output at all");
248
+ }
249
+ return commit("hold ceiling reached; empty-completion signal disabled");
250
+ }
251
+ return null;
252
+ },
253
+
254
+ verdictOnEnd(): ProbeVerdict {
255
+ if (decided) return decided;
256
+ if (!plan.enabled) return commit("probe disabled");
257
+ return endVerdict();
258
+ },
259
+
260
+ held(): UpstreamChunk[] {
261
+ return heldChunks;
262
+ },
263
+ };
264
+ }