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.
- package/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- 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
|
+
}
|