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,393 @@
1
+ /**
2
+ * Turn orchestrator — where money is actually spent.
3
+ *
4
+ * One turn = up to `escalation.maxAttempts` dispatches. Each attempt routes,
5
+ * dispatches, and runs the guarded probe. The probe holds output until the
6
+ * generation proves itself; on rejection the attempt is aborted (so no further
7
+ * tokens bill), recorded as wasted, and retried one tier up. Once a single
8
+ * byte reaches the client the attempt is committed and NEVER retried.
9
+ */
10
+
11
+ import type { CatalogSource } from "../catalog/types.ts";
12
+ import type { RouterConfig } from "../config/types.ts";
13
+ import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
14
+ import { createProbe, type Probe } from "../router/escalate.ts";
15
+ import {
16
+ TIER_ORDER,
17
+ type ConversationStore,
18
+ type Decision,
19
+ type ProbeVerdict,
20
+ type Router,
21
+ type Tier,
22
+ } from "../router/types.ts";
23
+ import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
24
+ import { createLogger } from "../util/log.ts";
25
+ import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
26
+
27
+ /**
28
+ * Same-tier failovers allowed per turn. A retryable upstream error (404
29
+ * model_unavailable, 429 rate_limit, 5xx upstream_error) indicts the slug,
30
+ * not the tier, so a failed model is first swapped for a sibling. Bounded
31
+ * because an exhausted tier must escalate rather than spin through the whole
32
+ * catalog while the client waits; `escalation.maxAttempts` caps total
33
+ * attempts regardless.
34
+ */
35
+ const MAX_SAME_TIER_FAILOVERS = 2;
36
+
37
+ export interface TurnDeps {
38
+ config: RouterConfig;
39
+ router: Router;
40
+ upstream: UpstreamClient;
41
+ ledger: Ledger;
42
+ conversations: ConversationStore;
43
+ catalog: CatalogSource;
44
+ }
45
+
46
+ /** A dead client connection surfaces as the sink throwing mid-stream. */
47
+ class SinkError extends Error {
48
+ constructor(cause: unknown) {
49
+ super(cause instanceof Error ? cause.message : String(cause));
50
+ this.name = "SinkError";
51
+ }
52
+ }
53
+
54
+ export async function runTurn(
55
+ req: NormRequest,
56
+ sink: ResponseSink,
57
+ deps: TurnDeps,
58
+ signal: AbortSignal,
59
+ ): Promise<void> {
60
+ const { config, router, upstream, ledger, conversations } = deps;
61
+ const log = createLogger(config.logLevel);
62
+ const state = conversations.load(req.conversationKey);
63
+ const turnNumber = state.turn + 1;
64
+
65
+ // The trigger list is the source of truth for enabled signals, except
66
+ // length_stop, which rides on its own toggle (escalation.escalateOnLengthStop).
67
+ const triggers = new Set(config.escalation.triggers);
68
+ if (config.escalation.escalateOnLengthStop) triggers.add("length_stop");
69
+ else triggers.delete("length_stop");
70
+
71
+ const maxAttempts = Math.max(1, config.escalation.maxAttempts);
72
+ let escalateFrom: Tier | undefined;
73
+ let escalations = 0;
74
+ // Slugs that returned a retryable upstream error on THIS turn, fed back
75
+ // into routing as excludeSlugs so a failover retry cannot re-pick the
76
+ // model that just failed.
77
+ const failedSlugs: string[] = [];
78
+ let sameTierFailovers = 0;
79
+ // A failover decision already routed inside onUpstreamError; the next
80
+ // loop iteration dispatches it instead of routing again.
81
+ let pendingDecision: Decision | null = null;
82
+
83
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
84
+ // Client disconnected before anything was dispatched: spend nothing.
85
+ if (signal.aborted) return;
86
+
87
+ let decision: Decision;
88
+ if (pendingDecision !== null) {
89
+ decision = pendingDecision;
90
+ pendingDecision = null;
91
+ } else {
92
+ try {
93
+ const opts: { attempt: number; escalateFrom?: Tier; excludeSlugs?: readonly string[] } = { attempt };
94
+ if (escalateFrom !== undefined) opts.escalateFrom = escalateFrom;
95
+ if (failedSlugs.length > 0) opts.excludeSlugs = failedSlugs;
96
+ decision = await router.route(req, opts);
97
+ } catch (err) {
98
+ await sink.error({ status: 500, code: "router_error", message: err instanceof Error ? err.message : String(err) });
99
+ return;
100
+ }
101
+ }
102
+
103
+ const body = req.renderUpstreamBody({
104
+ slug: decision.slug,
105
+ fallbacks: decision.fallbacks,
106
+ sessionId: decision.sessionId,
107
+ cacheBreakpointMessageIndices: decision.cacheBreakpointMessageIndices,
108
+ reasoning: decision.reasoning,
109
+ maxTokens: decision.maxTokens,
110
+ stripAssistantReasoning: decision.stripAssistantReasoning,
111
+ });
112
+
113
+ // Our own abort composes with the client's: escalation teardown and
114
+ // client disconnect both kill the upstream connection.
115
+ const attemptAbort = new AbortController();
116
+ const attemptSignal = AbortSignal.any([signal, attemptAbort.signal]);
117
+ const startedAt = Date.now();
118
+ let usage: UsageCounts = { ...EMPTY_USAGE };
119
+ let reportedUsd: number | null = null;
120
+ let servedSlug: string | null = null;
121
+ let finishReason: string | null = null;
122
+ let ttftMs: number | null = null;
123
+ let committed = false;
124
+ let dispatch: Dispatch | null = null;
125
+ let probe: Probe | null = null;
126
+ let generationId: string | null = null;
127
+
128
+ const writeEntry = async (fields: {
129
+ wasted: boolean;
130
+ escalationSignal: string | null;
131
+ error: string | null;
132
+ }): Promise<void> => {
133
+ if (generationId === null && dispatch !== null) {
134
+ generationId = await dispatch.generationId().catch(() => null);
135
+ }
136
+ ledger.record({
137
+ id: crypto.randomUUID(),
138
+ createdAtMs: Date.now(),
139
+ conversationKey: req.conversationKey,
140
+ sessionId: decision.sessionId,
141
+ turn: turnNumber,
142
+ requestedModel: req.requestedModel,
143
+ harnessId: req.harnessId,
144
+ slug: decision.slug,
145
+ servedSlug,
146
+ tier: decision.tier,
147
+ classificationSource: decision.classification.source,
148
+ reasons: decision.reasons,
149
+ predictedUsd: decision.forecast.expectedUsd,
150
+ reportedUsd,
151
+ usage,
152
+ attempt,
153
+ escalationSignal: fields.escalationSignal,
154
+ latencyMs: Date.now() - startedAt,
155
+ ttftMs,
156
+ finishReason,
157
+ wasted: fields.wasted,
158
+ upstreamGenerationId: generationId,
159
+ error: fields.error,
160
+ });
161
+ };
162
+
163
+ // "retry" re-enters the attempt loop; "done" means the turn is settled
164
+ // (client told, or client gone) and runTurn must return.
165
+ const onUpstreamError = async (err: unknown): Promise<"retry" | "done"> => {
166
+ const uerr =
167
+ err instanceof UpstreamError
168
+ ? err
169
+ : new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
170
+ if (uerr.kind === "aborted" || signal.aborted) {
171
+ // Client is gone; nothing to retry and no one to notify. Record the
172
+ // spend so far so the ledger stays honest.
173
+ await writeEntry({ wasted: !committed, escalationSignal: null, error: uerr.message });
174
+ return "done";
175
+ }
176
+ if (committed) {
177
+ // Bytes already reached the client: retrying is impossible. Report
178
+ // the failure; never sink.finish a truncated stream.
179
+ await writeEntry({ wasted: false, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
180
+ await sink.error(uerr.toWireError());
181
+ return "done";
182
+ }
183
+ if (uerr.retryable && attempt + 1 < maxAttempts) {
184
+ failedSlugs.push(decision.slug);
185
+ if (sameTierFailovers < MAX_SAME_TIER_FAILOVERS) {
186
+ // Before jumping a tier, try a DIFFERENT model in the same
187
+ // tier: a 404/429/5xx indicts the slug, not the tier.
188
+ let failover: Decision | null = null;
189
+ try {
190
+ failover = await router.route(req, { attempt: attempt + 1, excludeSlugs: failedSlugs });
191
+ } catch {
192
+ // A routing failure here must not kill the turn; tier
193
+ // escalation below may still find a model.
194
+ failover = null;
195
+ }
196
+ if (failover !== null && failover.tier === decision.tier && !failedSlugs.includes(failover.slug)) {
197
+ sameTierFailovers++;
198
+ failover.reasons = [
199
+ ...failover.reasons,
200
+ `failover: ${decision.slug} returned ${uerr.kind}; retrying ${failover.slug} in ${failover.tier}`,
201
+ ];
202
+ pendingDecision = failover;
203
+ await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
204
+ return "retry";
205
+ }
206
+ // No different candidate at this tier — the router widened on
207
+ // its own or only the failed slug qualifies. Fall through to
208
+ // tier escalation.
209
+ }
210
+ const topTier = TIER_ORDER[TIER_ORDER.length - 1];
211
+ if (decision.tier !== topTier) {
212
+ escalations++;
213
+ escalateFrom = decision.tier;
214
+ await writeEntry({
215
+ wasted: true,
216
+ escalationSignal: "upstream_error",
217
+ error: `${uerr.kind}: ${uerr.message}`,
218
+ });
219
+ return "retry";
220
+ }
221
+ }
222
+ // Non-retryable, or out of runway: fail the turn openly.
223
+ await writeEntry({ wasted: false, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
224
+ await sink.error(uerr.toWireError());
225
+ return "done";
226
+ };
227
+
228
+ let escalateVerdict: Extract<ProbeVerdict, { action: "escalate" }> | null = null;
229
+ let streamError: unknown = null;
230
+ let sinkDied = false;
231
+
232
+ try {
233
+ dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
234
+ } catch (err) {
235
+ streamError = err;
236
+ }
237
+
238
+ if (dispatch !== null) {
239
+ probe = createProbe(decision.probe, req, triggers);
240
+ const emit = async (chunk: UpstreamChunk): Promise<void> => {
241
+ try {
242
+ await sink.chunk(chunk);
243
+ } catch (err) {
244
+ throw new SinkError(err);
245
+ }
246
+ };
247
+ try {
248
+ for await (const chunk of dispatch.chunks) {
249
+ for (const ev of chunk.events) {
250
+ switch (ev.type) {
251
+ case "start":
252
+ servedSlug = ev.servedSlug;
253
+ if (ev.generationId !== null) generationId = ev.generationId;
254
+ break;
255
+ case "text":
256
+ case "reasoning":
257
+ if (ttftMs === null) ttftMs = Date.now() - startedAt;
258
+ break;
259
+ case "finish":
260
+ finishReason = ev.reason;
261
+ break;
262
+ case "usage":
263
+ usage = ev.usage;
264
+ reportedUsd = ev.reportedCostUsd;
265
+ break;
266
+ default:
267
+ break;
268
+ }
269
+ }
270
+ if (committed) {
271
+ await emit(chunk);
272
+ continue;
273
+ }
274
+ const verdict = probe.observe(chunk);
275
+ if (verdict === null) continue;
276
+ if (verdict.action === "commit") {
277
+ committed = true;
278
+ for (const heldChunk of probe.held()) await emit(heldChunk);
279
+ continue;
280
+ }
281
+ // Abort BEFORE breaking so the pending upstream read rejects and
282
+ // no further tokens bill while we tear down.
283
+ escalateVerdict = verdict;
284
+ attemptAbort.abort();
285
+ break;
286
+ }
287
+ if (!committed && escalateVerdict === null) {
288
+ const verdict = probe.verdictOnEnd();
289
+ if (verdict.action === "commit") {
290
+ committed = true;
291
+ for (const heldChunk of probe.held()) await emit(heldChunk);
292
+ } else {
293
+ escalateVerdict = verdict;
294
+ attemptAbort.abort();
295
+ }
296
+ }
297
+ } catch (err) {
298
+ if (escalateVerdict !== null || attemptAbort.signal.aborted) {
299
+ // Teardown noise from our own abort; the escalate path owns the outcome.
300
+ } else if (err instanceof SinkError) {
301
+ sinkDied = true;
302
+ } else {
303
+ streamError = err;
304
+ }
305
+ } finally {
306
+ // Never leak the upstream connection, whatever happened above.
307
+ attemptAbort.abort();
308
+ }
309
+ }
310
+
311
+ if (sinkDied) {
312
+ await writeEntry({ wasted: !committed, escalationSignal: null, error: "client connection lost" });
313
+ return;
314
+ }
315
+ if (streamError !== null) {
316
+ if ((await onUpstreamError(streamError)) === "retry") continue;
317
+ return;
318
+ }
319
+
320
+ if (escalateVerdict !== null) {
321
+ const hasRunway = attempt + 1 < maxAttempts && decision.probe.escalateTo !== null;
322
+ if (hasRunway) {
323
+ escalations++;
324
+ escalateFrom = decision.tier;
325
+ await writeEntry({ wasted: true, escalationSignal: escalateVerdict.signal, error: null });
326
+ log.info("escalating turn", {
327
+ signal: escalateVerdict.signal,
328
+ reason: escalateVerdict.reason,
329
+ from: decision.tier,
330
+ attempt,
331
+ });
332
+ continue;
333
+ }
334
+ // No higher tier or no attempts left: commit what was held. A mediocre
335
+ // answer beats an error.
336
+ if (probe !== null) {
337
+ committed = true;
338
+ for (const heldChunk of probe.held()) await sink.chunk(heldChunk);
339
+ }
340
+ }
341
+
342
+ await writeEntry({ wasted: false, escalationSignal: null, error: null });
343
+
344
+ state.turn = turnNumber;
345
+ state.currentSlug = servedSlug ?? decision.slug;
346
+ // Capture the previously-served tier BEFORE overwriting it, so the
347
+ // hysteresis re-arm below can tell whether this turn changed tier.
348
+ const prevTier = state.currentTier;
349
+ state.currentTier = decision.tier;
350
+ state.escalations += escalations;
351
+ // Hysteresis window. Only re-arm when the served tier actually changed
352
+ // (or this turn escalated). Re-arming on EVERY turn — even a trivial one
353
+ // served by a held hard model — extends the lock forever: the classifier
354
+ // keeps saying trivial, but the window keeps getting pushed out, so the
355
+ // router never downgrades. A stable tier needs no new hold; let the
356
+ // existing window expire so the router can move down when the work is
357
+ // actually easy.
358
+ const tierChanged = prevTier !== decision.tier;
359
+ if (tierChanged || escalations > 0) {
360
+ state.stickyUntilTurn =
361
+ turnNumber + (escalations > 0 ? config.hysteresis.holdTurnsAfterEscalation : config.hysteresis.holdTurns);
362
+ }
363
+ // Reported cost is authoritative; fall back to the forecast so the
364
+ // budget guard still works when the provider omits cost.
365
+ state.spentUsd += reportedUsd ?? decision.forecast.expectedUsd;
366
+ state.lastPromptTokens = usage.promptTokens;
367
+ if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
368
+ // Non-zero cache traffic is direct evidence the upstream cache exists.
369
+ state.cacheWarmSlug = servedSlug ?? decision.slug;
370
+ state.cacheWarmAtMs = Date.now();
371
+ }
372
+ state.updatedAtMs = Date.now();
373
+ conversations.save(state);
374
+
375
+ const summary: TurnSummary = {
376
+ servedSlug: servedSlug ?? decision.slug,
377
+ tier: decision.tier,
378
+ attempts: attempt + 1,
379
+ predictedUsd: decision.forecast.expectedUsd,
380
+ reportedUsd,
381
+ usage,
382
+ reasons: decision.reasons,
383
+ escalated: escalations > 0,
384
+ };
385
+ await sink.finish(summary);
386
+ return;
387
+ }
388
+
389
+ // Every attempt looped without settling; the retry paths above always
390
+ // continue or return, so reaching here means maxAttempts was 0-guarded and
391
+ // the first iteration never ran. Fail openly rather than silently.
392
+ await sink.error({ status: 502, code: "attempts_exhausted", message: "all routing attempts exhausted" });
393
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Token estimation: bytes → tokens without a tokenizer. The ratio is
3
+ * self-calibrating: `estimatePromptTokens` remembers the bytes it estimated
4
+ * from (keyed by conversation), and the ledger folds those bytes against the
5
+ * ACTUAL billed prompt tokens when the turn is recorded, converging
6
+ * `token_calibration` per tokenizer family on measurement.
7
+ */
8
+
9
+ import type { Ledger } from "../cost/types.ts";
10
+ import type { NormRequest } from "../wire/types.ts";
11
+
12
+ /** Chars-per-token when nothing better is known. Conservative-ish for mixed prose+code. */
13
+ export const DEFAULT_BYTES_PER_TOKEN = 3.6;
14
+
15
+ /**
16
+ * Flat per-image allowance. Vision encoders tile images; a typical tile budget
17
+ * lands around 1100 tokens per image across OpenAI/Anthropic/Gemini.
18
+ */
19
+ export const IMAGE_TOKEN_ALLOWANCE = 1100;
20
+
21
+ /**
22
+ * Per-family defaults, keyed by lowercased `architecture.tokenizer`. DeepSeek
23
+ * traffic is code-dominant, and BPE tokenizers emit more tokens per character
24
+ * on code than on prose, so its ratio sits below the prose-oriented default.
25
+ */
26
+ const FAMILY_BYTES_PER_TOKEN: Record<string, number> = {
27
+ claude: 3.6,
28
+ gpt: 3.6,
29
+ gemini: 3.6,
30
+ llama: 3.6,
31
+ qwen: 3.6,
32
+ qwen3: 3.6,
33
+ grok: 3.6,
34
+ mistral: 3.6,
35
+ deepseek: 3.4,
36
+ };
37
+
38
+ function familyKey(tokenizer: string): string {
39
+ return tokenizer.trim().toLowerCase();
40
+ }
41
+
42
+ export function estimateTokens(bytes: number, tokenizer: string, ledger: Ledger | null): number {
43
+ const ratio = ledger?.tokenRatio(tokenizer) ?? FAMILY_BYTES_PER_TOKEN[familyKey(tokenizer)] ?? DEFAULT_BYTES_PER_TOKEN;
44
+ return Math.max(0, Math.ceil(bytes / ratio));
45
+ }
46
+
47
+ /**
48
+ * Pending estimates, keyed by conversation. The router estimates before
49
+ * dispatch; the ledger consumes the estimate when recording the turn, pairing
50
+ * estimated bytes with actual billed tokens for calibration. In-memory only:
51
+ * the data is statistical, so a restart losing it costs nothing.
52
+ */
53
+ const PENDING_CAP = 1024;
54
+ const pendingEstimates = new Map<string, { tokenizer: string; bytes: number }>();
55
+
56
+ export function estimatePromptTokens(req: NormRequest, tokenizer: string, ledger: Ledger | null): number {
57
+ let images = 0;
58
+ for (const message of req.messages) images += message.images;
59
+ const tokens = estimateTokens(req.promptBytes, tokenizer, ledger) + images * IMAGE_TOKEN_ALLOWANCE;
60
+ if (pendingEstimates.size >= PENDING_CAP && !pendingEstimates.has(req.conversationKey)) {
61
+ // Map iteration order is insertion order: drop the eldest.
62
+ const eldest = pendingEstimates.keys().next();
63
+ if (!eldest.done) pendingEstimates.delete(eldest.value);
64
+ }
65
+ pendingEstimates.set(req.conversationKey, { tokenizer, bytes: req.promptBytes });
66
+ return tokens;
67
+ }
68
+
69
+ /** Internal: called by cost/ledger.ts when recording a turn. */
70
+ export function consumePendingEstimate(conversationKey: string): { tokenizer: string; bytes: number } | null {
71
+ const pending = pendingEstimates.get(conversationKey) ?? null;
72
+ pendingEstimates.delete(conversationKey);
73
+ return pending;
74
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * OpenRouter transport.
3
+ *
4
+ * Deliberately thin: POSTs the body the wire rendered, decodes SSE via
5
+ * `sse-parse.ts`, and classifies failures into `UpstreamErrorKind`s the turn
6
+ * orchestrator can act on. No routing policy lives here.
7
+ */
8
+
9
+ import type { RouterConfig } from "../config/types.ts";
10
+ import { createLogger } from "../util/log.ts";
11
+ import type { UpstreamChunk } from "../wire/types.ts";
12
+ import { parseSse } from "./sse-parse.ts";
13
+ import {
14
+ UpstreamError,
15
+ type Dispatch,
16
+ type DispatchOptions,
17
+ type UpstreamClient,
18
+ type UpstreamErrorKind,
19
+ } from "./types.ts";
20
+
21
+ // OpenRouter reports context overflow as a 400 with a human message; the
22
+ // message is the only signal, so match the phrasings providers actually send.
23
+ const CONTEXT_LENGTH_RE =
24
+ /context[ _-]?length|context[ _-]?window|maximum context|too many tokens|reduce (?:the |your )?(?:length|prompt)|prompt is too long|token limit/i;
25
+
26
+ function asRec(v: unknown): Record<string, unknown> | null {
27
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
28
+ }
29
+
30
+ function classifyStatus(status: number, body: unknown): UpstreamError {
31
+ const rec = asRec(body);
32
+ const errRec = rec ? asRec(rec.error) : null;
33
+ const msg = errRec?.message ?? rec?.message;
34
+ const message = typeof msg === "string" && msg !== "" ? msg : `OpenRouter HTTP ${status}`;
35
+ const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError =>
36
+ new UpstreamError(kind, status, message, retryable, body);
37
+ if (status === 401 || status === 403) return fail("auth", false);
38
+ // 402 = out of credits; retrying changes nothing, only topping up does.
39
+ if (status === 402) return fail("auth", false);
40
+ if (status === 429) return fail("rate_limit", true);
41
+ if (status === 400) {
42
+ return CONTEXT_LENGTH_RE.test(message) ? fail("context_length", false) : fail("invalid_request", false);
43
+ }
44
+ // Provider routing may recover a missing model on the next attempt.
45
+ if (status === 404) return fail("model_unavailable", true);
46
+ if (status >= 500) return fail("upstream_error", true);
47
+ return fail("upstream_error", status === 408);
48
+ }
49
+
50
+ async function httpError(res: Response): Promise<UpstreamError> {
51
+ let body: unknown = null;
52
+ try {
53
+ body = await res.json();
54
+ } catch {
55
+ // Non-JSON error body — the status alone drives classification.
56
+ }
57
+ return classifyStatus(res.status, body);
58
+ }
59
+
60
+ // Mid-stream failure arrives as an SSE payload: {"error": {message, code}}.
61
+ function streamError(raw: unknown): UpstreamError {
62
+ const rec = asRec(raw) ?? {};
63
+ const message = typeof rec.message === "string" && rec.message !== "" ? rec.message : "OpenRouter stream error";
64
+ const code = rec.code;
65
+ const status = typeof code === "number" && Number.isFinite(code) ? code : 0;
66
+ if (status === 0) return new UpstreamError("upstream_error", 0, message, true, raw);
67
+ return classifyStatus(status, raw);
68
+ }
69
+
70
+ // Transport-level rejection: timeout, caller abort, or a socket failure.
71
+ function transportError(err: unknown): UpstreamError {
72
+ if (err instanceof UpstreamError) return err;
73
+ const name = err instanceof Error ? err.name : "";
74
+ if (name === "TimeoutError") return new UpstreamError("timeout", 0, "OpenRouter request timed out", true);
75
+ if (name === "AbortError") return new UpstreamError("aborted", 0, "request aborted", false);
76
+ return new UpstreamError("network", 0, err instanceof Error ? err.message : String(err), true);
77
+ }
78
+
79
+ export function createOpenRouterClient(cfg: RouterConfig): UpstreamClient {
80
+ const baseUrl = cfg.openrouter.baseUrl.replace(/\/+$/, "");
81
+ const log = createLogger(cfg.logLevel);
82
+
83
+ // Compose the per-request timeout with the caller's cancellation.
84
+ function composeSignal(caller: AbortSignal | undefined): AbortSignal | null {
85
+ const timeout = cfg.openrouter.timeoutMs > 0 ? AbortSignal.timeout(cfg.openrouter.timeoutMs) : null;
86
+ if (caller && timeout) return AbortSignal.any([caller, timeout]);
87
+ return caller ?? timeout;
88
+ }
89
+
90
+ function headers(extra: Record<string, string>): Record<string, string> {
91
+ const h: Record<string, string> = {
92
+ "content-type": "application/json",
93
+ "x-title": cfg.openrouter.title,
94
+ ...extra,
95
+ };
96
+ // /models is public; an empty key must not produce a broken Bearer header.
97
+ if (cfg.openrouter.apiKey !== "") h.authorization = `Bearer ${cfg.openrouter.apiKey}`;
98
+ if (cfg.openrouter.referer) h["http-referer"] = cfg.openrouter.referer;
99
+ return h;
100
+ }
101
+
102
+ return {
103
+ async dispatch(opts: DispatchOptions): Promise<Dispatch> {
104
+ const signal = composeSignal(opts.signal);
105
+ // session_id pins provider stickiness so prompt caches stay warm across
106
+ // turns. No `usage` flag: OpenRouter now returns full cost and cache
107
+ // accounting on every response, and the old opt-in is a no-op.
108
+ // The wire's rendered body wins on conflicts, except the two invariants
109
+ // this client owns: streaming and session id.
110
+ const body = { ...opts.body, stream: true, session_id: opts.sessionId };
111
+ let res: Response;
112
+ try {
113
+ res = await fetch(`${baseUrl}/chat/completions`, {
114
+ method: "POST",
115
+ headers: headers({ "x-session-id": opts.sessionId }),
116
+ body: JSON.stringify(body),
117
+ signal,
118
+ });
119
+ } catch (err) {
120
+ throw transportError(err);
121
+ }
122
+ if (!res.ok) throw await httpError(res);
123
+ if (!res.body) throw new UpstreamError("upstream_error", res.status, "response had no body", true);
124
+
125
+ const parsed = parseSse(res.body, (msg, fields) => log.warn(msg, fields));
126
+ let resolveId!: (id: string | null) => void;
127
+ const idPromise = new Promise<string | null>((resolve) => {
128
+ resolveId = resolve;
129
+ });
130
+ let idResolved = false;
131
+ const resolveOnce = (id: string | null): void => {
132
+ if (!idResolved) {
133
+ idResolved = true;
134
+ resolveId(id);
135
+ }
136
+ };
137
+
138
+ const chunks = (async function* (): AsyncGenerator<UpstreamChunk> {
139
+ try {
140
+ for await (const chunk of parsed) {
141
+ // OpenRouter can fail mid-stream as an SSE payload instead of an
142
+ // HTTP status; reclassify it so callers see one error shape.
143
+ const errPayload = chunk.raw.error;
144
+ if (errPayload !== undefined && errPayload !== null) throw streamError(errPayload);
145
+ if (!idResolved && typeof chunk.raw.id === "string") resolveOnce(chunk.raw.id);
146
+ yield chunk;
147
+ }
148
+ } catch (err) {
149
+ throw transportError(err);
150
+ } finally {
151
+ // generationId() must never hang, even if the consumer abandons
152
+ // the stream before the first chunk.
153
+ resolveOnce(null);
154
+ }
155
+ })();
156
+
157
+ return { chunks, generationId: () => idPromise };
158
+ },
159
+
160
+ async complete(
161
+ body: Record<string, unknown>,
162
+ signal: AbortSignal,
163
+ ): Promise<{ text: string; costUsd: number | null }> {
164
+ // Single attempt by design: this feeds the classifier adjudicator,
165
+ // where a retry would double adjudication cost on ambiguous turns.
166
+ let res: Response;
167
+ try {
168
+ res = await fetch(`${baseUrl}/chat/completions`, {
169
+ method: "POST",
170
+ headers: headers({}),
171
+ body: JSON.stringify({ ...body, stream: false }),
172
+ signal: composeSignal(signal),
173
+ });
174
+ } catch (err) {
175
+ throw transportError(err);
176
+ }
177
+ if (!res.ok) throw await httpError(res);
178
+ const json = asRec(await res.json());
179
+ const choices = json?.choices;
180
+ const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
181
+ const message = choice0 ? asRec(choice0.message) : null;
182
+ const content = message?.content;
183
+ const usage = json ? asRec(json.usage) : null;
184
+ const cost = usage?.cost;
185
+ return {
186
+ text: typeof content === "string" ? content : "",
187
+ costUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
188
+ };
189
+ },
190
+
191
+ async fetchModels(signal?: AbortSignal): Promise<unknown[]> {
192
+ let res: Response;
193
+ try {
194
+ res = await fetch(`${baseUrl}/models`, { headers: headers({}), signal: composeSignal(signal) });
195
+ } catch (err) {
196
+ throw transportError(err);
197
+ }
198
+ if (!res.ok) throw await httpError(res);
199
+ const data = asRec(await res.json())?.data;
200
+ if (!Array.isArray(data)) {
201
+ throw new UpstreamError("upstream_error", res.status, "models payload had no data array", true);
202
+ }
203
+ return data;
204
+ },
205
+
206
+ async fetchModelsForUser(signal?: AbortSignal): Promise<unknown[]> {
207
+ let res: Response;
208
+ try {
209
+ res = await fetch(`${baseUrl}/models/user`, { headers: headers({}), signal: composeSignal(signal) });
210
+ } catch (err) {
211
+ throw transportError(err);
212
+ }
213
+ if (!res.ok) throw await httpError(res);
214
+ const data = asRec(await res.json())?.data;
215
+ if (!Array.isArray(data)) {
216
+ throw new UpstreamError("upstream_error", res.status, "models/user payload had no data array", true);
217
+ }
218
+ return data;
219
+ },
220
+ };
221
+ }