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,279 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
NormMessage,
|
|
3
|
+
NormRequest,
|
|
4
|
+
NormTool,
|
|
5
|
+
NormToolCall,
|
|
6
|
+
ReasoningLevel,
|
|
7
|
+
Role,
|
|
8
|
+
UpstreamMutations,
|
|
9
|
+
} from "../types.ts";
|
|
10
|
+
import { conversationKeyOf } from "../../util/hash.ts";
|
|
11
|
+
import { invalidRequest, modelNotFound } from "./errors.ts";
|
|
12
|
+
|
|
13
|
+
const ROLES: Record<string, true> = { system: true, developer: true, user: true, assistant: true, tool: true };
|
|
14
|
+
|
|
15
|
+
const REASONING_LEVELS: Record<string, true> = {
|
|
16
|
+
off: true,
|
|
17
|
+
minimal: true,
|
|
18
|
+
low: true,
|
|
19
|
+
medium: true,
|
|
20
|
+
high: true,
|
|
21
|
+
xhigh: true,
|
|
22
|
+
max: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* UTF-8 byte length without allocating an encoded copy. Lone surrogates count
|
|
27
|
+
* as 3 bytes, matching TextEncoder's U+FFFD replacement.
|
|
28
|
+
*/
|
|
29
|
+
function utf8Bytes(s: string): number {
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (let i = 0; i < s.length; i++) {
|
|
32
|
+
const c = s.charCodeAt(i);
|
|
33
|
+
if (c < 0x80) n += 1;
|
|
34
|
+
else if (c < 0x800) n += 2;
|
|
35
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
|
|
36
|
+
n += 4;
|
|
37
|
+
i++;
|
|
38
|
+
} else n += 3;
|
|
39
|
+
}
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeMessage(raw: unknown, index: number): NormMessage {
|
|
44
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
45
|
+
throw invalidRequest(`messages[${index}] must be an object`);
|
|
46
|
+
}
|
|
47
|
+
const m = raw as {
|
|
48
|
+
role?: unknown;
|
|
49
|
+
content?: unknown;
|
|
50
|
+
tool_calls?: unknown;
|
|
51
|
+
tool_call_id?: unknown;
|
|
52
|
+
name?: unknown;
|
|
53
|
+
};
|
|
54
|
+
if (typeof m.role !== "string" || !(m.role in ROLES)) {
|
|
55
|
+
throw invalidRequest(`messages[${index}].role must be one of: system, developer, user, assistant, tool`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Flatten content into lossy text plus an image count. The flattened view
|
|
59
|
+
// feeds classification only; dispatch bytes come from renderUpstreamBody.
|
|
60
|
+
let text = "";
|
|
61
|
+
let images = 0;
|
|
62
|
+
if (typeof m.content === "string") {
|
|
63
|
+
text = m.content;
|
|
64
|
+
} else if (Array.isArray(m.content)) {
|
|
65
|
+
const parts: string[] = [];
|
|
66
|
+
for (const part of m.content) {
|
|
67
|
+
if (typeof part !== "object" || part === null) continue;
|
|
68
|
+
const p = part as { type?: unknown; text?: unknown };
|
|
69
|
+
if (p.type === "text" && typeof p.text === "string") parts.push(p.text);
|
|
70
|
+
else if (p.type === "image_url") images++;
|
|
71
|
+
}
|
|
72
|
+
text = parts.join("\n");
|
|
73
|
+
} else if (m.content !== null && m.content !== undefined) {
|
|
74
|
+
throw invalidRequest(`messages[${index}].content must be a string or a content-part array`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const toolCalls: NormToolCall[] = [];
|
|
78
|
+
if (Array.isArray(m.tool_calls)) {
|
|
79
|
+
for (const tc of m.tool_calls) {
|
|
80
|
+
if (typeof tc !== "object" || tc === null) continue;
|
|
81
|
+
const t = tc as { id?: unknown; function?: unknown };
|
|
82
|
+
const fn =
|
|
83
|
+
typeof t.function === "object" && t.function !== null
|
|
84
|
+
? (t.function as { name?: unknown; arguments?: unknown })
|
|
85
|
+
: {};
|
|
86
|
+
toolCalls.push({
|
|
87
|
+
id: typeof t.id === "string" ? t.id : "",
|
|
88
|
+
name: typeof fn.name === "string" ? fn.name : "",
|
|
89
|
+
argsJson: typeof fn.arguments === "string" ? fn.arguments : "",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const msg: NormMessage = {
|
|
95
|
+
role: m.role as Role,
|
|
96
|
+
text,
|
|
97
|
+
images,
|
|
98
|
+
textBytes: utf8Bytes(text),
|
|
99
|
+
toolCalls,
|
|
100
|
+
};
|
|
101
|
+
if (m.role === "tool") {
|
|
102
|
+
if (typeof m.tool_call_id === "string") msg.toolCallId = m.tool_call_id;
|
|
103
|
+
if (typeof m.name === "string") msg.toolName = m.name;
|
|
104
|
+
}
|
|
105
|
+
return msg;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeTools(raw: unknown): NormTool[] {
|
|
109
|
+
if (!Array.isArray(raw)) return [];
|
|
110
|
+
const tools: NormTool[] = [];
|
|
111
|
+
for (const t of raw) {
|
|
112
|
+
if (typeof t !== "object" || t === null) continue;
|
|
113
|
+
const fn = (t as { function?: unknown }).function;
|
|
114
|
+
if (typeof fn !== "object" || fn === null) continue;
|
|
115
|
+
const f = fn as { name?: unknown; description?: unknown; parameters?: unknown };
|
|
116
|
+
tools.push({
|
|
117
|
+
name: typeof f.name === "string" ? f.name : "",
|
|
118
|
+
description: typeof f.description === "string" ? f.description : "",
|
|
119
|
+
schemaBytes: utf8Bytes(JSON.stringify(f.parameters ?? {})),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return tools;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseReasoning(b: { reasoning?: unknown; reasoning_effort?: unknown }): ReasoningLevel | undefined {
|
|
126
|
+
// The OpenRouter-style object wins over the OpenAI-style flat field: it is
|
|
127
|
+
// the spelling we emit upstream, so it is the client's clearest intent.
|
|
128
|
+
if (typeof b.reasoning === "object" && b.reasoning !== null) {
|
|
129
|
+
const r = b.reasoning as { effort?: unknown; enabled?: unknown };
|
|
130
|
+
if (typeof r.effort === "string" && r.effort in REASONING_LEVELS) return r.effort as ReasoningLevel;
|
|
131
|
+
if (r.enabled === false) return "off";
|
|
132
|
+
}
|
|
133
|
+
if (typeof b.reasoning_effort === "string" && b.reasoning_effort in REASONING_LEVELS) {
|
|
134
|
+
return b.reasoning_effort as ReasoningLevel;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderUpstreamBody(
|
|
140
|
+
original: Record<string, unknown>,
|
|
141
|
+
m: UpstreamMutations,
|
|
142
|
+
): Record<string, unknown> {
|
|
143
|
+
// Deep clone: an escalation retry renders the same original again for a
|
|
144
|
+
// different model, so a render must never touch the stored body.
|
|
145
|
+
const body = structuredClone(original);
|
|
146
|
+
body.model = m.slug;
|
|
147
|
+
if (m.fallbacks.length > 0) body.models = [m.slug, ...m.fallbacks];
|
|
148
|
+
body.session_id = m.sessionId;
|
|
149
|
+
// The guard always consumes a stream; the sink re-buffers for non-streaming clients.
|
|
150
|
+
body.stream = true;
|
|
151
|
+
// OpenRouter returns usage unconditionally and the parameter is deprecated.
|
|
152
|
+
delete body.stream_options;
|
|
153
|
+
|
|
154
|
+
if (m.maxTokens !== undefined) {
|
|
155
|
+
// Respect whichever max-token spelling the client used.
|
|
156
|
+
if ("max_completion_tokens" in body) body.max_completion_tokens = m.maxTokens;
|
|
157
|
+
else body.max_tokens = m.maxTokens;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Normalize to the OpenRouter spelling; the two fields must never coexist.
|
|
161
|
+
delete body.reasoning_effort;
|
|
162
|
+
if (m.reasoning === undefined) delete body.reasoning;
|
|
163
|
+
else if (m.reasoning === "off") body.reasoning = { enabled: false };
|
|
164
|
+
else body.reasoning = { effort: m.reasoning };
|
|
165
|
+
|
|
166
|
+
// messages was validated to be an array of objects at parse time.
|
|
167
|
+
const messages = body.messages as Record<string, unknown>[];
|
|
168
|
+
if (m.stripAssistantReasoning) {
|
|
169
|
+
// omp replays reasoning fields for what it believes is a local backend;
|
|
170
|
+
// most OpenRouter upstreams reject every spelling.
|
|
171
|
+
for (const msg of messages) {
|
|
172
|
+
if (msg.role !== "assistant") continue;
|
|
173
|
+
delete msg.reasoning;
|
|
174
|
+
delete msg.reasoning_content;
|
|
175
|
+
delete msg.reasoning_details;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const idx of m.cacheBreakpointMessageIndices) {
|
|
179
|
+
const msg = messages[idx];
|
|
180
|
+
if (!msg) continue;
|
|
181
|
+
const content = msg.content;
|
|
182
|
+
if (typeof content === "string") {
|
|
183
|
+
msg.content = [{ type: "text", text: content, cache_control: { type: "ephemeral" } }];
|
|
184
|
+
} else if (Array.isArray(content)) {
|
|
185
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
186
|
+
const part = content[i] as { type?: unknown; cache_control?: unknown } | undefined;
|
|
187
|
+
if (part && typeof part === "object" && part.type === "text") {
|
|
188
|
+
part.cache_control = { type: "ephemeral" };
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return body;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
198
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
199
|
+
throw invalidRequest("Request body must be a JSON object");
|
|
200
|
+
}
|
|
201
|
+
const b = body as Record<string, unknown>;
|
|
202
|
+
|
|
203
|
+
// Harness identity for per-harness budgets and toast scoping. omp sends
|
|
204
|
+
// this via the provider block's `headers:` override; absent ⇒ single harness.
|
|
205
|
+
const harnessId = (headers.get("x-omp-harness") ?? "").trim();
|
|
206
|
+
|
|
207
|
+
if (typeof b.model !== "string" || b.model.length === 0) {
|
|
208
|
+
throw invalidRequest("model must be a non-empty string");
|
|
209
|
+
}
|
|
210
|
+
// Strip any provider prefix so `auto` and `auto-model-router/auto` both resolve to
|
|
211
|
+
// the profile id.
|
|
212
|
+
const requestedModel = b.model.slice(b.model.lastIndexOf("/") + 1);
|
|
213
|
+
if (requestedModel.length === 0) throw modelNotFound(b.model);
|
|
214
|
+
|
|
215
|
+
if (!Array.isArray(b.messages) || b.messages.length === 0) {
|
|
216
|
+
throw invalidRequest("messages must be a non-empty array");
|
|
217
|
+
}
|
|
218
|
+
const messages = b.messages.map((msg, i) => normalizeMessage(msg, i));
|
|
219
|
+
const tools = normalizeTools(b.tools);
|
|
220
|
+
|
|
221
|
+
const tc = b.tool_choice;
|
|
222
|
+
const forcedToolChoice =
|
|
223
|
+
tc !== null &&
|
|
224
|
+
tc !== undefined &&
|
|
225
|
+
(typeof tc === "object" || (typeof tc === "string" && tc !== "auto" && tc !== "none"));
|
|
226
|
+
|
|
227
|
+
let promptBytes = 0;
|
|
228
|
+
for (const msg of messages) promptBytes += msg.textBytes;
|
|
229
|
+
for (const t of tools) promptBytes += t.schemaBytes + utf8Bytes(t.name) + utf8Bytes(t.description);
|
|
230
|
+
|
|
231
|
+
let hasImages = false;
|
|
232
|
+
for (const msg of messages) {
|
|
233
|
+
if (msg.images > 0) {
|
|
234
|
+
hasImages = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Conversation identity: the leading system/developer run plus the first
|
|
240
|
+
// message after it, matching how OpenRouter fingerprints conversations.
|
|
241
|
+
const systemParts: string[] = [];
|
|
242
|
+
let firstNonSystemText = "";
|
|
243
|
+
for (const msg of messages) {
|
|
244
|
+
if (msg.role === "system" || msg.role === "developer") systemParts.push(msg.text);
|
|
245
|
+
else {
|
|
246
|
+
firstNonSystemText = msg.text;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const conversationKey = conversationKeyOf(systemParts.join("\n"), firstNonSystemText);
|
|
251
|
+
|
|
252
|
+
const maxTokens =
|
|
253
|
+
typeof b.max_completion_tokens === "number"
|
|
254
|
+
? b.max_completion_tokens
|
|
255
|
+
: typeof b.max_tokens === "number"
|
|
256
|
+
? b.max_tokens
|
|
257
|
+
: undefined;
|
|
258
|
+
const temperature = typeof b.temperature === "number" ? b.temperature : undefined;
|
|
259
|
+
const reasoning = parseReasoning(b);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
protocol: "openai-chat",
|
|
263
|
+
conversationKey,
|
|
264
|
+
harnessId,
|
|
265
|
+
requestedModel,
|
|
266
|
+
messages,
|
|
267
|
+
tools,
|
|
268
|
+
forcedToolChoice,
|
|
269
|
+
stream: b.stream === true,
|
|
270
|
+
hasImages,
|
|
271
|
+
promptBytes,
|
|
272
|
+
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
273
|
+
...(temperature !== undefined ? { temperature } : {}),
|
|
274
|
+
...(reasoning !== undefined ? { reasoning } : {}),
|
|
275
|
+
renderUpstreamBody(m: UpstreamMutations): Record<string, unknown> {
|
|
276
|
+
return renderUpstreamBody(b, m);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { ResponseSink, TurnSummary, UpstreamChunk, WireError } from "../types.ts";
|
|
2
|
+
import { encodeSseData, SSE_DONE_BYTES } from "../../util/sse.ts";
|
|
3
|
+
import { renderErrorEnvelope } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The four observability fields every routed response carries. Streaming
|
|
7
|
+
* responses cannot use real headers (they flush with the first chunk, long
|
|
8
|
+
* before the TurnSummary exists), so the streaming sink emits these in a final
|
|
9
|
+
* `x_auto_model_router` SSE frame instead — see createStreamingSink.finish.
|
|
10
|
+
*/
|
|
11
|
+
function summaryFields(summary: TurnSummary): Record<string, unknown> {
|
|
12
|
+
return {
|
|
13
|
+
model: summary.servedSlug,
|
|
14
|
+
tier: summary.tier,
|
|
15
|
+
cost_usd: summary.reportedUsd ?? summary.predictedUsd,
|
|
16
|
+
attempts: summary.attempts,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createStreamingSink(virtualModel: string): { sink: ResponseSink; response: Response } {
|
|
21
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
22
|
+
let closed = false;
|
|
23
|
+
const body = new ReadableStream<Uint8Array>({
|
|
24
|
+
start(c) {
|
|
25
|
+
controller = c;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const send = (bytes: Uint8Array): void => {
|
|
29
|
+
if (!closed) controller?.enqueue(bytes);
|
|
30
|
+
};
|
|
31
|
+
const close = (): void => {
|
|
32
|
+
if (!closed) {
|
|
33
|
+
closed = true;
|
|
34
|
+
controller?.close();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const sink: ResponseSink = {
|
|
39
|
+
chunk(chunk: UpstreamChunk) {
|
|
40
|
+
const raw = chunk.raw;
|
|
41
|
+
// The client asked for the virtual id and must see it, so its own
|
|
42
|
+
// bookkeeping stays consistent; the served slug stays observable
|
|
43
|
+
// under x_auto_model_router. The tier is only known at finish time, so
|
|
44
|
+
// per-chunk frames carry the slug and the final frame carries all
|
|
45
|
+
// summary fields.
|
|
46
|
+
send(
|
|
47
|
+
encodeSseData({
|
|
48
|
+
...raw,
|
|
49
|
+
model: virtualModel,
|
|
50
|
+
x_auto_model_router: { model: typeof raw.model === "string" ? raw.model : null },
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
error(error: WireError) {
|
|
55
|
+
// Headers (and likely chunks) are already on the wire; the only
|
|
56
|
+
// channel left for the failure is one SSE frame in the OpenAI error
|
|
57
|
+
// envelope, then an early close.
|
|
58
|
+
send(encodeSseData(renderErrorEnvelope(error)));
|
|
59
|
+
close();
|
|
60
|
+
},
|
|
61
|
+
finish(summary: TurnSummary) {
|
|
62
|
+
// Response headers flushed with the first chunk, so x-auto-model-router-*
|
|
63
|
+
// cannot be real headers here; this final frame is their carrier.
|
|
64
|
+
send(encodeSseData({ x_auto_model_router: summaryFields(summary) }));
|
|
65
|
+
send(SSE_DONE_BYTES);
|
|
66
|
+
close();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const response = new Response(body, {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: {
|
|
73
|
+
"content-type": "text/event-stream",
|
|
74
|
+
"cache-control": "no-cache",
|
|
75
|
+
connection: "keep-alive",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return { sink, response };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Aggregation state for one completion choice. */
|
|
82
|
+
interface ChoiceAggregate {
|
|
83
|
+
role: string;
|
|
84
|
+
text: string;
|
|
85
|
+
reasoning: string;
|
|
86
|
+
toolCalls: Map<number, { id: string; name: string; args: string }>;
|
|
87
|
+
finishReason: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createBufferedSink(virtualModel: string): {
|
|
91
|
+
sink: ResponseSink;
|
|
92
|
+
response: Promise<Response>;
|
|
93
|
+
} {
|
|
94
|
+
let resolveResponse!: (r: Response) => void;
|
|
95
|
+
const response = new Promise<Response>((resolve) => {
|
|
96
|
+
resolveResponse = resolve;
|
|
97
|
+
});
|
|
98
|
+
let settled = false;
|
|
99
|
+
|
|
100
|
+
let id: string | null = null;
|
|
101
|
+
let created: number | null = null;
|
|
102
|
+
const choices = new Map<number, ChoiceAggregate>();
|
|
103
|
+
let usage: unknown = null;
|
|
104
|
+
|
|
105
|
+
const sink: ResponseSink = {
|
|
106
|
+
chunk(chunk: UpstreamChunk) {
|
|
107
|
+
const raw = chunk.raw;
|
|
108
|
+
if (typeof raw.id === "string") id = raw.id;
|
|
109
|
+
if (typeof raw.created === "number") created = raw.created;
|
|
110
|
+
if (Array.isArray(raw.choices)) {
|
|
111
|
+
for (const c of raw.choices) {
|
|
112
|
+
if (typeof c !== "object" || c === null) continue;
|
|
113
|
+
const ch = c as { index?: unknown; delta?: unknown; finish_reason?: unknown };
|
|
114
|
+
const index = typeof ch.index === "number" ? ch.index : 0;
|
|
115
|
+
let agg = choices.get(index);
|
|
116
|
+
if (!agg) {
|
|
117
|
+
agg = { role: "assistant", text: "", reasoning: "", toolCalls: new Map(), finishReason: null };
|
|
118
|
+
choices.set(index, agg);
|
|
119
|
+
}
|
|
120
|
+
if (typeof ch.delta === "object" && ch.delta !== null) {
|
|
121
|
+
const delta = ch.delta as {
|
|
122
|
+
role?: unknown;
|
|
123
|
+
content?: unknown;
|
|
124
|
+
reasoning?: unknown;
|
|
125
|
+
reasoning_content?: unknown;
|
|
126
|
+
tool_calls?: unknown;
|
|
127
|
+
};
|
|
128
|
+
if (typeof delta.role === "string") agg.role = delta.role;
|
|
129
|
+
if (typeof delta.content === "string") agg.text += delta.content;
|
|
130
|
+
// Upstreams disagree on the spelling; both land in `reasoning`.
|
|
131
|
+
if (typeof delta.reasoning === "string") agg.reasoning += delta.reasoning;
|
|
132
|
+
else if (typeof delta.reasoning_content === "string") agg.reasoning += delta.reasoning_content;
|
|
133
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
134
|
+
for (const tc of delta.tool_calls) {
|
|
135
|
+
if (typeof tc !== "object" || tc === null) continue;
|
|
136
|
+
const t = tc as { index?: unknown; id?: unknown; function?: unknown };
|
|
137
|
+
const ti = typeof t.index === "number" ? t.index : 0;
|
|
138
|
+
let ta = agg.toolCalls.get(ti);
|
|
139
|
+
if (!ta) {
|
|
140
|
+
ta = { id: "", name: "", args: "" };
|
|
141
|
+
agg.toolCalls.set(ti, ta);
|
|
142
|
+
}
|
|
143
|
+
if (typeof t.id === "string" && t.id.length > 0 && ta.id.length === 0) ta.id = t.id;
|
|
144
|
+
if (typeof t.function === "object" && t.function !== null) {
|
|
145
|
+
const fn = t.function as { name?: unknown; arguments?: unknown };
|
|
146
|
+
if (typeof fn.name === "string" && fn.name.length > 0 && ta.name.length === 0) {
|
|
147
|
+
ta.name = fn.name;
|
|
148
|
+
}
|
|
149
|
+
if (typeof fn.arguments === "string") ta.args += fn.arguments;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (typeof ch.finish_reason === "string") agg.finishReason = ch.finish_reason;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (typeof raw.usage === "object" && raw.usage !== null) usage = raw.usage;
|
|
158
|
+
},
|
|
159
|
+
error(error: WireError) {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
resolveResponse(
|
|
163
|
+
new Response(JSON.stringify(renderErrorEnvelope(error)), {
|
|
164
|
+
status: error.status,
|
|
165
|
+
headers: { "content-type": "application/json" },
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
finish(summary: TurnSummary) {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
if (choices.size === 0) {
|
|
173
|
+
choices.set(0, { role: "assistant", text: "", reasoning: "", toolCalls: new Map(), finishReason: null });
|
|
174
|
+
}
|
|
175
|
+
const completion: Record<string, unknown> = {
|
|
176
|
+
id: id ?? "chatcmpl-auto-model-router",
|
|
177
|
+
object: "chat.completion",
|
|
178
|
+
created: created ?? Math.floor(Date.now() / 1000),
|
|
179
|
+
model: virtualModel,
|
|
180
|
+
choices: [...choices.entries()]
|
|
181
|
+
.sort((a, b) => a[0] - b[0])
|
|
182
|
+
.map(([index, agg]) => {
|
|
183
|
+
const message: Record<string, unknown> = { role: agg.role, content: agg.text };
|
|
184
|
+
if (agg.reasoning.length > 0) message.reasoning = agg.reasoning;
|
|
185
|
+
if (agg.toolCalls.size > 0) {
|
|
186
|
+
message.tool_calls = [...agg.toolCalls.entries()]
|
|
187
|
+
.sort((a, b) => a[0] - b[0])
|
|
188
|
+
.map(([, ta]) => ({
|
|
189
|
+
id: ta.id,
|
|
190
|
+
type: "function",
|
|
191
|
+
function: { name: ta.name, arguments: ta.args },
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
return { index, message, finish_reason: agg.finishReason ?? "stop" };
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
if (usage !== null) completion.usage = usage;
|
|
198
|
+
// Header names are hyphenated by HTTP convention while the SSE frame
|
|
199
|
+
// keys are snake_case by JSON convention. Deriving one from the other
|
|
200
|
+
// silently produced `x-auto-model-router-cost_usd`.
|
|
201
|
+
const headers: Record<string, string> = {
|
|
202
|
+
"content-type": "application/json",
|
|
203
|
+
"x-auto-model-router-model": summary.servedSlug,
|
|
204
|
+
"x-auto-model-router-tier": summary.tier,
|
|
205
|
+
"x-auto-model-router-cost-usd": String(summary.reportedUsd ?? summary.predictedUsd),
|
|
206
|
+
"x-auto-model-router-attempts": String(summary.attempts),
|
|
207
|
+
};
|
|
208
|
+
resolveResponse(new Response(JSON.stringify(completion), { status: 200, headers }));
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return { sink, response };
|
|
213
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol-agnostic boundary between a client-facing wire (OpenAI chat
|
|
3
|
+
* completions today, pi-native later) and the routing core.
|
|
4
|
+
*
|
|
5
|
+
* The core never parses a wire format. A front end produces a `NormRequest`
|
|
6
|
+
* and consumes `UpstreamChunk`s through a `ResponseSink`. Anything the core
|
|
7
|
+
* does not understand rides along in `renderUpstreamBody()` output and in
|
|
8
|
+
* `UpstreamChunk.raw`, so unknown fields survive the round trip untouched.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { UsageCounts } from "../cost/types.ts";
|
|
12
|
+
|
|
13
|
+
export type WireProtocol = "openai-chat" | "pi-native";
|
|
14
|
+
|
|
15
|
+
export type Role = "system" | "developer" | "user" | "assistant" | "tool";
|
|
16
|
+
|
|
17
|
+
/** One tool call requested by an assistant turn. */
|
|
18
|
+
export interface NormToolCall {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** Raw JSON argument text as the model emitted it (may be invalid JSON). */
|
|
22
|
+
argsJson: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A conversation message flattened for feature extraction.
|
|
27
|
+
*
|
|
28
|
+
* `text` is a lossy concatenation used only for classification. Nothing is
|
|
29
|
+
* ever dispatched from it — the wire's own `renderUpstreamBody()` owns the
|
|
30
|
+
* bytes that reach OpenRouter.
|
|
31
|
+
*/
|
|
32
|
+
export interface NormMessage {
|
|
33
|
+
role: Role;
|
|
34
|
+
text: string;
|
|
35
|
+
/** Number of image parts on this message. */
|
|
36
|
+
images: number;
|
|
37
|
+
/** Bytes of text content, cheaper than recounting. */
|
|
38
|
+
textBytes: number;
|
|
39
|
+
toolCalls: NormToolCall[];
|
|
40
|
+
/** Set when `role === "tool"`; links back to the assistant call. */
|
|
41
|
+
toolCallId?: string;
|
|
42
|
+
/** Name of the tool, for `role === "tool"` messages that carry it. */
|
|
43
|
+
toolName?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A tool exposed to the model, sized for prompt-cost accounting. */
|
|
47
|
+
export interface NormTool {
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
/** Serialized byte length of the JSON schema. Tool schemas dominate omp prompts. */
|
|
51
|
+
schemaBytes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ReasoningLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
55
|
+
|
|
56
|
+
export interface NormRequest {
|
|
57
|
+
protocol: WireProtocol;
|
|
58
|
+
/**
|
|
59
|
+
* Stable conversation identity: sha256 over the system prompt plus the
|
|
60
|
+
* first non-system message. Matches how OpenRouter fingerprints
|
|
61
|
+
* conversations, so our `session_id` and their implicit key agree.
|
|
62
|
+
*/
|
|
63
|
+
conversationKey: string;
|
|
64
|
+
/**
|
|
65
|
+
* Harness/session identifier from the `X-Omp-Harness` request header, when
|
|
66
|
+
* the client sends one. Lets multiple coding harnesses share one router
|
|
67
|
+
* while keeping per-harness daily budgets and toast scoping. Empty when the
|
|
68
|
+
* client sends no header (single-harness default).
|
|
69
|
+
*/
|
|
70
|
+
harnessId: string;
|
|
71
|
+
/** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
|
|
72
|
+
requestedModel: string;
|
|
73
|
+
messages: NormMessage[];
|
|
74
|
+
tools: NormTool[];
|
|
75
|
+
/** True when the client forced a specific tool. */
|
|
76
|
+
forcedToolChoice: boolean;
|
|
77
|
+
stream: boolean;
|
|
78
|
+
maxTokens?: number;
|
|
79
|
+
temperature?: number;
|
|
80
|
+
reasoning?: ReasoningLevel;
|
|
81
|
+
hasImages: boolean;
|
|
82
|
+
/** Total prompt bytes across messages, system prompt, and tool schemas. */
|
|
83
|
+
promptBytes: number;
|
|
84
|
+
/**
|
|
85
|
+
* Renders the body to POST to OpenRouter for a chosen model. The core passes
|
|
86
|
+
* mutations it computed; the wire owns serialization so unknown client
|
|
87
|
+
* fields pass through verbatim.
|
|
88
|
+
*/
|
|
89
|
+
renderUpstreamBody(m: UpstreamMutations): Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Core-computed changes the wire must apply when rendering the upstream body. */
|
|
93
|
+
export interface UpstreamMutations {
|
|
94
|
+
/** Concrete slug to dispatch to. Replaces the virtual model. */
|
|
95
|
+
slug: string;
|
|
96
|
+
/** Same-tier fallbacks for OpenRouter's `models[]` array. */
|
|
97
|
+
fallbacks: string[];
|
|
98
|
+
/** Forwarded as `session_id` to pin provider stickiness and group logs. */
|
|
99
|
+
sessionId: string;
|
|
100
|
+
/** Cache breakpoints to inject, as message indices. Empty ⇒ inject none. */
|
|
101
|
+
cacheBreakpointMessageIndices: number[];
|
|
102
|
+
/** Effective reasoning level, after clamping to what the target supports. */
|
|
103
|
+
reasoning: ReasoningLevel | undefined;
|
|
104
|
+
/** Clamp for the target's published completion ceiling. */
|
|
105
|
+
maxTokens: number | undefined;
|
|
106
|
+
/** Drop assistant reasoning-replay fields the target rejects. */
|
|
107
|
+
stripAssistantReasoning: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
|
|
111
|
+
|
|
112
|
+
/** Interpreted view of one upstream SSE chunk. */
|
|
113
|
+
export type StreamEvent =
|
|
114
|
+
| { type: "start"; servedSlug: string; generationId: string | null }
|
|
115
|
+
| { type: "text"; delta: string }
|
|
116
|
+
| { type: "reasoning"; delta: string }
|
|
117
|
+
| { type: "tool_call"; index: number; id?: string; name?: string; argsDelta?: string }
|
|
118
|
+
| { type: "finish"; reason: FinishReason }
|
|
119
|
+
| { type: "usage"; usage: UsageCounts; reportedCostUsd: number | null };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One upstream chunk, carried as both raw bytes and interpreted events.
|
|
123
|
+
*
|
|
124
|
+
* The escalation guard reads `events`; the wire forwards `raw` (with `model`
|
|
125
|
+
* rewritten). Keeping both means interpretation gaps never drop client-visible
|
|
126
|
+
* fields.
|
|
127
|
+
*/
|
|
128
|
+
export interface UpstreamChunk {
|
|
129
|
+
raw: Record<string, unknown>;
|
|
130
|
+
events: StreamEvent[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface WireError {
|
|
134
|
+
status: number;
|
|
135
|
+
code: string;
|
|
136
|
+
message: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Outcome of a fully-resolved turn, after any escalation retries. */
|
|
140
|
+
export interface TurnSummary {
|
|
141
|
+
servedSlug: string;
|
|
142
|
+
tier: string;
|
|
143
|
+
attempts: number;
|
|
144
|
+
predictedUsd: number;
|
|
145
|
+
reportedUsd: number | null;
|
|
146
|
+
usage: UsageCounts;
|
|
147
|
+
reasons: string[];
|
|
148
|
+
escalated: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Client-facing sink. A front end renders these to its own wire format. */
|
|
152
|
+
export interface ResponseSink {
|
|
153
|
+
chunk(chunk: UpstreamChunk): void | Promise<void>;
|
|
154
|
+
error(error: WireError): void | Promise<void>;
|
|
155
|
+
finish(summary: TurnSummary): void | Promise<void>;
|
|
156
|
+
}
|