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,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental SSE decoder for OpenRouter's chat-completions stream.
|
|
3
|
+
*
|
|
4
|
+
* Handles the realities of the wire: frames split across TCP chunks (even
|
|
5
|
+
* mid-line or mid-codepoint), `: OPENROUTER PROCESSING` keep-alive comments,
|
|
6
|
+
* and the terminal `data: [DONE]`. A malformed JSON frame is skipped with a
|
|
7
|
+
* warning rather than thrown — one bad frame must not abort a paid generation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { UsageCounts } from "../cost/types.ts";
|
|
11
|
+
import type { FinishReason, StreamEvent, UpstreamChunk } from "../wire/types.ts";
|
|
12
|
+
|
|
13
|
+
export type SseWarning = (message: string, fields?: Record<string, unknown>) => void;
|
|
14
|
+
|
|
15
|
+
function asRec(v: unknown): Record<string, unknown> | null {
|
|
16
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function num(v: unknown): number {
|
|
20
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mapFinishReason(v: unknown): FinishReason | null {
|
|
24
|
+
if (typeof v !== "string" || v === "") return null;
|
|
25
|
+
switch (v) {
|
|
26
|
+
case "stop":
|
|
27
|
+
case "length":
|
|
28
|
+
case "tool_calls":
|
|
29
|
+
case "content_filter":
|
|
30
|
+
return v;
|
|
31
|
+
default:
|
|
32
|
+
// Anything else ("error", provider-specific strings) means the
|
|
33
|
+
// generation failed; the escalation guard treats it as such.
|
|
34
|
+
return "error";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function reasoningDelta(delta: Record<string, unknown>): string | null {
|
|
39
|
+
// Providers disagree on the field: OpenAI-style `reasoning`, DeepSeek-style
|
|
40
|
+
// `reasoning_content`, OpenRouter-normalized `reasoning_details`.
|
|
41
|
+
const reasoning = delta.reasoning;
|
|
42
|
+
if (typeof reasoning === "string" && reasoning.length > 0) return reasoning;
|
|
43
|
+
const reasoningContent = delta.reasoning_content;
|
|
44
|
+
if (typeof reasoningContent === "string" && reasoningContent.length > 0) return reasoningContent;
|
|
45
|
+
const details = delta.reasoning_details;
|
|
46
|
+
if (Array.isArray(details)) {
|
|
47
|
+
let out = "";
|
|
48
|
+
for (const d of details) {
|
|
49
|
+
const rec = asRec(d);
|
|
50
|
+
if (rec && typeof rec.text === "string") out += rec.text;
|
|
51
|
+
}
|
|
52
|
+
if (out.length > 0) return out;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function usageEvent(payload: Record<string, unknown>): StreamEvent | null {
|
|
58
|
+
const usage = asRec(payload.usage);
|
|
59
|
+
if (!usage) return null;
|
|
60
|
+
const promptDetails = asRec(usage.prompt_tokens_details);
|
|
61
|
+
const completionDetails = asRec(usage.completion_tokens_details);
|
|
62
|
+
const counts: UsageCounts = {
|
|
63
|
+
// prompt_tokens already INCLUDES cached tokens; cachedTokens is a
|
|
64
|
+
// sub-count for pricing, never added on top.
|
|
65
|
+
promptTokens: num(usage.prompt_tokens),
|
|
66
|
+
cachedTokens: num(promptDetails?.cached_tokens),
|
|
67
|
+
cacheWriteTokens: num(promptDetails?.cache_write_tokens),
|
|
68
|
+
completionTokens: num(usage.completion_tokens),
|
|
69
|
+
reasoningTokens: num(completionDetails?.reasoning_tokens),
|
|
70
|
+
// Usage reports carry no image count; per-image surcharges are forecast-side.
|
|
71
|
+
images: 0,
|
|
72
|
+
};
|
|
73
|
+
const cost = usage.cost;
|
|
74
|
+
const reportedCostUsd = typeof cost === "number" && Number.isFinite(cost) ? cost : null;
|
|
75
|
+
return { type: "usage", usage: counts, reportedCostUsd };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Decodes an SSE byte stream into interpreted chunks. Single-pass; abandoning
|
|
80
|
+
* the generator releases the underlying reader, but callers should abort the
|
|
81
|
+
* fetch signal to actually tear down the connection.
|
|
82
|
+
*/
|
|
83
|
+
export async function* parseSse(
|
|
84
|
+
stream: ReadableStream<Uint8Array>,
|
|
85
|
+
warn?: SseWarning,
|
|
86
|
+
): AsyncGenerator<UpstreamChunk> {
|
|
87
|
+
const reader = stream.getReader();
|
|
88
|
+
// Streaming decoder so a multi-byte codepoint split across chunks survives.
|
|
89
|
+
const decoder = new TextDecoder();
|
|
90
|
+
let buf = "";
|
|
91
|
+
let dataLines: string[] = [];
|
|
92
|
+
let started = false;
|
|
93
|
+
|
|
94
|
+
const buildChunk = (data: string): UpstreamChunk | "done" | null => {
|
|
95
|
+
if (data === "[DONE]") return "done";
|
|
96
|
+
let parsed: unknown;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(data);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
warn?.("skipping malformed SSE payload", {
|
|
101
|
+
error: err instanceof Error ? err.message : String(err),
|
|
102
|
+
snippet: data.length > 160 ? `${data.slice(0, 160)}…` : data,
|
|
103
|
+
});
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const raw = asRec(parsed);
|
|
107
|
+
if (!raw) {
|
|
108
|
+
warn?.("skipping non-object SSE payload", { snippet: data.slice(0, 160) });
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const events: StreamEvent[] = [];
|
|
113
|
+
const id = typeof raw.id === "string" && raw.id !== "" ? raw.id : null;
|
|
114
|
+
const model = typeof raw.model === "string" && raw.model !== "" ? raw.model : null;
|
|
115
|
+
if (!started && id !== null && model !== null) {
|
|
116
|
+
started = true;
|
|
117
|
+
events.push({ type: "start", servedSlug: model, generationId: id });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const choices = raw.choices;
|
|
121
|
+
const choice0 = Array.isArray(choices) && choices.length > 0 ? asRec(choices[0]) : null;
|
|
122
|
+
const delta = choice0 ? asRec(choice0.delta) : null;
|
|
123
|
+
if (delta) {
|
|
124
|
+
const content = delta.content;
|
|
125
|
+
if (typeof content === "string" && content.length > 0) {
|
|
126
|
+
events.push({ type: "text", delta: content });
|
|
127
|
+
}
|
|
128
|
+
const reasoning = reasoningDelta(delta);
|
|
129
|
+
if (reasoning !== null) events.push({ type: "reasoning", delta: reasoning });
|
|
130
|
+
const toolCalls = delta.tool_calls;
|
|
131
|
+
if (Array.isArray(toolCalls)) {
|
|
132
|
+
for (const tcRaw of toolCalls) {
|
|
133
|
+
const tc = asRec(tcRaw);
|
|
134
|
+
if (!tc) continue;
|
|
135
|
+
const ev: {
|
|
136
|
+
type: "tool_call";
|
|
137
|
+
index: number;
|
|
138
|
+
id?: string;
|
|
139
|
+
name?: string;
|
|
140
|
+
argsDelta?: string;
|
|
141
|
+
} = { type: "tool_call", index: typeof tc.index === "number" ? tc.index : 0 };
|
|
142
|
+
// Fragments only carry id/name on their first chunk and arguments
|
|
143
|
+
// as they stream; attach exactly what this fragment supplies.
|
|
144
|
+
if (typeof tc.id === "string" && tc.id !== "") ev.id = tc.id;
|
|
145
|
+
const fn = asRec(tc.function);
|
|
146
|
+
if (fn) {
|
|
147
|
+
if (typeof fn.name === "string" && fn.name !== "") ev.name = fn.name;
|
|
148
|
+
if (typeof fn.arguments === "string" && fn.arguments !== "") ev.argsDelta = fn.arguments;
|
|
149
|
+
}
|
|
150
|
+
events.push(ev);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const finish = mapFinishReason(choice0?.finish_reason);
|
|
155
|
+
if (finish !== null) events.push({ type: "finish", reason: finish });
|
|
156
|
+
const usage = usageEvent(raw);
|
|
157
|
+
if (usage !== null) events.push(usage);
|
|
158
|
+
|
|
159
|
+
return { raw, events };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Returns a chunk when the line completed a frame, "done" on [DONE], else null.
|
|
163
|
+
const processLine = (line: string): UpstreamChunk | "done" | null => {
|
|
164
|
+
if (line === "") {
|
|
165
|
+
// Blank line terminates a frame.
|
|
166
|
+
if (dataLines.length === 0) return null;
|
|
167
|
+
const data = dataLines.join("\n");
|
|
168
|
+
dataLines = [];
|
|
169
|
+
return buildChunk(data);
|
|
170
|
+
}
|
|
171
|
+
// Keep-alive comments (": OPENROUTER PROCESSING") carry no data.
|
|
172
|
+
if (line.startsWith(":")) return null;
|
|
173
|
+
if (line.startsWith("data:")) {
|
|
174
|
+
// Per SSE spec, a single leading space after the colon is stripped.
|
|
175
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
176
|
+
}
|
|
177
|
+
// event:/id:/retry: fields carry nothing we route on.
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
for (;;) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
buf += decoder.decode(value, { stream: true });
|
|
186
|
+
let nl: number;
|
|
187
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
188
|
+
const line = buf.slice(0, nl);
|
|
189
|
+
buf = buf.slice(nl + 1);
|
|
190
|
+
const frame = processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
191
|
+
if (frame === "done") return;
|
|
192
|
+
if (frame !== null) yield frame;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Flush a trailing partial line and a final frame missing its blank-line
|
|
196
|
+
// terminator; some providers close the stream right after the payload.
|
|
197
|
+
buf += decoder.decode();
|
|
198
|
+
if (buf.length > 0) {
|
|
199
|
+
const frame = processLine(buf.endsWith("\r") ? buf.slice(0, -1) : buf);
|
|
200
|
+
if (frame === "done") return;
|
|
201
|
+
if (frame !== null) yield frame;
|
|
202
|
+
}
|
|
203
|
+
const frame = processLine("");
|
|
204
|
+
if (frame !== null && frame !== "done") yield frame;
|
|
205
|
+
} finally {
|
|
206
|
+
reader.releaseLock();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter transport contracts.
|
|
3
|
+
*
|
|
4
|
+
* The client is deliberately thin: it POSTs a body the wire layer rendered,
|
|
5
|
+
* parses SSE into `UpstreamChunk`s, and classifies failures. It holds no
|
|
6
|
+
* routing policy — that lives in `router/`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { UpstreamChunk, WireError } from "../wire/types.ts";
|
|
10
|
+
|
|
11
|
+
export type UpstreamErrorKind =
|
|
12
|
+
| "auth"
|
|
13
|
+
| "rate_limit"
|
|
14
|
+
| "context_length"
|
|
15
|
+
| "model_unavailable"
|
|
16
|
+
| "invalid_request"
|
|
17
|
+
| "moderation"
|
|
18
|
+
| "upstream_error"
|
|
19
|
+
| "timeout"
|
|
20
|
+
| "network"
|
|
21
|
+
| "aborted";
|
|
22
|
+
|
|
23
|
+
export class UpstreamError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly kind: UpstreamErrorKind,
|
|
26
|
+
readonly status: number,
|
|
27
|
+
message: string,
|
|
28
|
+
readonly retryable: boolean,
|
|
29
|
+
readonly body?: unknown,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "UpstreamError";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toWireError(): WireError {
|
|
36
|
+
return { status: this.status, code: this.kind, message: this.message };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DispatchOptions {
|
|
41
|
+
body: Record<string, unknown>;
|
|
42
|
+
/** Forwarded as the `x-session-id` header, mirroring body `session_id`. */
|
|
43
|
+
sessionId: string;
|
|
44
|
+
signal: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A live upstream generation.
|
|
49
|
+
*
|
|
50
|
+
* `chunks` is single-pass. The escalation guard may abandon it before
|
|
51
|
+
* completion; callers MUST abort the signal in that case so the upstream
|
|
52
|
+
* connection is torn down and no further tokens are billed.
|
|
53
|
+
*/
|
|
54
|
+
export interface Dispatch {
|
|
55
|
+
chunks: AsyncIterable<UpstreamChunk>;
|
|
56
|
+
/** Resolves once the generation id is known, i.e. on the first chunk. */
|
|
57
|
+
generationId(): Promise<string | null>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UpstreamClient {
|
|
61
|
+
/** Streaming chat completion. Always requests `stream: true` upstream. */
|
|
62
|
+
dispatch(opts: DispatchOptions): Promise<Dispatch>;
|
|
63
|
+
/**
|
|
64
|
+
* Non-streaming single-shot, used by the classifier adjudicator.
|
|
65
|
+
* Returns assistant text and the reported cost.
|
|
66
|
+
*/
|
|
67
|
+
complete(body: Record<string, unknown>, signal: AbortSignal): Promise<{ text: string; costUsd: number | null }>;
|
|
68
|
+
/** Raw catalog fetch. Returns the parsed `data` array untouched. */
|
|
69
|
+
fetchModels(signal?: AbortSignal): Promise<unknown[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Key-scoped catalog fetch (`GET /models/user`). Returns the models
|
|
72
|
+
* available to the configured key under active guardrails and preferences.
|
|
73
|
+
*/
|
|
74
|
+
fetchModelsForUser(signal?: AbortSignal): Promise<unknown[]>;
|
|
75
|
+
}
|
package/src/util/hash.ts
ADDED
|
Binary file
|
package/src/util/log.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
2
|
+
|
|
3
|
+
export type LogLevel = RouterConfig["logLevel"];
|
|
4
|
+
|
|
5
|
+
export interface Logger {
|
|
6
|
+
error(msg: string, fields?: Record<string, unknown>): void;
|
|
7
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
8
|
+
info(msg: string, fields?: Record<string, unknown>): void;
|
|
9
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
13
|
+
silent: Number.POSITIVE_INFINITY,
|
|
14
|
+
error: 40,
|
|
15
|
+
warn: 30,
|
|
16
|
+
info: 20,
|
|
17
|
+
debug: 10,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function formatField(v: unknown): string {
|
|
21
|
+
if (typeof v === "string") return /[\s=]/.test(v) ? JSON.stringify(v) : v;
|
|
22
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(v) ?? "null";
|
|
25
|
+
} catch {
|
|
26
|
+
return String(v);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Single-line stderr logger. Suppressed levels return before any formatting
|
|
32
|
+
* or allocation, so debug calls on the hot path are effectively free.
|
|
33
|
+
*/
|
|
34
|
+
export function createLogger(level: LogLevel): Logger {
|
|
35
|
+
const threshold = LEVEL_RANK[level];
|
|
36
|
+
const emit = (rank: number, tag: string, msg: string, fields?: Record<string, unknown>): void => {
|
|
37
|
+
if (rank < threshold) return;
|
|
38
|
+
let line = `${new Date().toISOString()} ${tag} ${msg}`;
|
|
39
|
+
if (fields !== undefined) {
|
|
40
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
41
|
+
if (v === undefined) continue;
|
|
42
|
+
line += ` ${k}=${formatField(v)}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
process.stderr.write(line + "\n");
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
error: (msg, fields) => emit(LEVEL_RANK.error, "ERROR", msg, fields),
|
|
49
|
+
warn: (msg, fields) => emit(LEVEL_RANK.warn, "WARN", msg, fields),
|
|
50
|
+
info: (msg, fields) => emit(LEVEL_RANK.info, "INFO", msg, fields),
|
|
51
|
+
debug: (msg, fields) => emit(LEVEL_RANK.debug, "DEBUG", msg, fields),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite bootstrap. This is the ONLY migration path: every table the router
|
|
3
|
+
* needs is created here, idempotently, so boot order never matters.
|
|
4
|
+
*
|
|
5
|
+
* Tables:
|
|
6
|
+
* - catalog_cache: single-row (id = 1) store of the last raw OpenRouter
|
|
7
|
+
* catalog payload, so restarts route from disk while a refresh is pending.
|
|
8
|
+
* - ledger: one row per dispatched upstream generation (see cost/types.ts
|
|
9
|
+
* LedgerEntry). `reasons`, `usage`, and `cost_breakdown` are JSON text.
|
|
10
|
+
* - token_calibration: running sums of estimated prompt bytes vs. actual
|
|
11
|
+
* billed prompt tokens per tokenizer family (see tokens/estimate.ts).
|
|
12
|
+
* - conversations: per-conversation routing memory, written by the
|
|
13
|
+
* RouterBrain slice's ConversationStore (it codes against these columns).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import { mkdirSync } from "node:fs";
|
|
18
|
+
import { dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Bump when a migration is added; guarded below so reopening never regresses it. */
|
|
21
|
+
const USER_VERSION = 4;
|
|
22
|
+
|
|
23
|
+
const MIGRATIONS = `
|
|
24
|
+
CREATE TABLE IF NOT EXISTS catalog_cache (
|
|
25
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
26
|
+
payload TEXT NOT NULL,
|
|
27
|
+
fetched_at_ms INTEGER NOT NULL,
|
|
28
|
+
etag TEXT,
|
|
29
|
+
key_scoped INTEGER NOT NULL DEFAULT 0
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
created_at_ms INTEGER NOT NULL,
|
|
35
|
+
conversation_key TEXT NOT NULL,
|
|
36
|
+
session_id TEXT NOT NULL,
|
|
37
|
+
turn INTEGER NOT NULL,
|
|
38
|
+
requested_model TEXT NOT NULL,
|
|
39
|
+
harness_id TEXT NOT NULL DEFAULT '',
|
|
40
|
+
slug TEXT NOT NULL,
|
|
41
|
+
served_slug TEXT,
|
|
42
|
+
tier TEXT NOT NULL,
|
|
43
|
+
classification_source TEXT NOT NULL,
|
|
44
|
+
reasons TEXT NOT NULL,
|
|
45
|
+
predicted_usd REAL NOT NULL,
|
|
46
|
+
reported_usd REAL,
|
|
47
|
+
usage TEXT NOT NULL,
|
|
48
|
+
cost_breakdown TEXT,
|
|
49
|
+
attempt INTEGER NOT NULL,
|
|
50
|
+
escalation_signal TEXT,
|
|
51
|
+
latency_ms INTEGER NOT NULL,
|
|
52
|
+
ttft_ms INTEGER,
|
|
53
|
+
finish_reason TEXT,
|
|
54
|
+
wasted INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
upstream_generation_id TEXT,
|
|
56
|
+
error TEXT
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_conversation ON ledger (conversation_key);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_created ON ledger (created_at_ms);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_slug ON ledger (slug);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS token_calibration (
|
|
63
|
+
tokenizer TEXT PRIMARY KEY,
|
|
64
|
+
est_bytes INTEGER NOT NULL,
|
|
65
|
+
actual_tokens INTEGER NOT NULL,
|
|
66
|
+
samples INTEGER NOT NULL
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
70
|
+
key TEXT PRIMARY KEY,
|
|
71
|
+
session_id TEXT NOT NULL,
|
|
72
|
+
turn INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
current_slug TEXT,
|
|
74
|
+
current_tier TEXT,
|
|
75
|
+
sticky_until_turn INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
escalations INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
spent_usd REAL NOT NULL DEFAULT 0,
|
|
78
|
+
last_prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
cache_warm_slug TEXT,
|
|
80
|
+
cache_warm_at_ms INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
updated_at_ms INTEGER NOT NULL DEFAULT 0
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
-- v2: catalog_cache gains key_scoped provenance. ALTER TABLE ADD COLUMN is
|
|
85
|
+
-- idempotent only via a guard; SQLite has no IF NOT EXISTS for columns, so
|
|
86
|
+
-- probe pragma_table_info and add when absent.
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
const MIGRATE_V2 = `
|
|
90
|
+
ALTER TABLE catalog_cache ADD COLUMN key_scoped INTEGER NOT NULL DEFAULT 0;
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const MIGRATE_V3 = `
|
|
94
|
+
ALTER TABLE ledger ADD COLUMN harness_id TEXT NOT NULL DEFAULT '';
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
// v4: ledger gains error_kind, so per-model trust can count only failures the
|
|
98
|
+
// MODEL is responsible for. Before this, any non-null `error` counted against a
|
|
99
|
+
// model's reliability — including client aborts, account-level auth/policy
|
|
100
|
+
// refusals, and guardrail `model_unavailable`, none of which say anything about
|
|
101
|
+
// the model's quality. Those spurious demotions shrink the candidate pool and
|
|
102
|
+
// push traffic onto a handful of survivors.
|
|
103
|
+
//
|
|
104
|
+
// Existing rows are backfilled from the stored `error` text, which turn.ts
|
|
105
|
+
// writes as `"<kind>: <message>"` (the abort path writes the bare message
|
|
106
|
+
// "request aborted"). Anything unrecognised stays NULL and is treated as
|
|
107
|
+
// model-attributable, preserving the old, stricter behaviour for rows we
|
|
108
|
+
// cannot classify.
|
|
109
|
+
const MIGRATE_V4 = `
|
|
110
|
+
ALTER TABLE ledger ADD COLUMN error_kind TEXT;
|
|
111
|
+
|
|
112
|
+
UPDATE ledger SET error_kind = CASE
|
|
113
|
+
WHEN error IS NULL THEN NULL
|
|
114
|
+
WHEN error = 'request aborted' THEN 'aborted'
|
|
115
|
+
WHEN instr(error, ': ') > 0 THEN substr(error, 1, instr(error, ': ') - 1)
|
|
116
|
+
ELSE NULL
|
|
117
|
+
END
|
|
118
|
+
WHERE error IS NOT NULL;
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
export function openDb(path: string): Database {
|
|
122
|
+
// ":memory:" has no parent directory to create.
|
|
123
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
124
|
+
const db = new Database(path);
|
|
125
|
+
// WAL + NORMAL: single-writer local service; favours read latency on the turn hot path.
|
|
126
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
127
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
128
|
+
db.exec(MIGRATIONS);
|
|
129
|
+
// PRAGMA user_version always returns exactly one row with one integer column.
|
|
130
|
+
const versionRow = db.query("PRAGMA user_version").get() as { user_version: number };
|
|
131
|
+
if (versionRow.user_version < USER_VERSION) {
|
|
132
|
+
const cacheCols = db.query("PRAGMA table_info(catalog_cache)").all() as { name: string }[];
|
|
133
|
+
if (!cacheCols.some((c) => c.name === "key_scoped")) db.exec(MIGRATE_V2);
|
|
134
|
+
const ledgerCols = db.query("PRAGMA table_info(ledger)").all() as { name: string }[];
|
|
135
|
+
if (!ledgerCols.some((c) => c.name === "harness_id")) db.exec(MIGRATE_V3);
|
|
136
|
+
if (!ledgerCols.some((c) => c.name === "error_kind")) db.exec(MIGRATE_V4);
|
|
137
|
+
db.exec(`PRAGMA user_version = ${USER_VERSION}`);
|
|
138
|
+
}
|
|
139
|
+
return db;
|
|
140
|
+
}
|
package/src/util/sse.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE encoding helpers for the client-facing wire. The OpenAI front end needs
|
|
3
|
+
* exactly two frame shapes; both go through one shared TextEncoder because a
|
|
4
|
+
* frame is emitted per token on the hot path.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
/** One `data:` frame carrying a JSON payload, terminated by a blank line. */
|
|
10
|
+
export function sseDataFrame(value: unknown): string {
|
|
11
|
+
return `data: ${JSON.stringify(value)}\n\n`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Encoded variant of {@link sseDataFrame} for stream hot paths. */
|
|
15
|
+
export function encodeSseData(value: unknown): Uint8Array {
|
|
16
|
+
return encoder.encode(sseDataFrame(value));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Terminal frame of every OpenAI SSE stream. */
|
|
20
|
+
export const SSE_DONE_FRAME = "data: [DONE]\n\n";
|
|
21
|
+
|
|
22
|
+
/** Pre-encoded {@link SSE_DONE_FRAME}. */
|
|
23
|
+
export const SSE_DONE_BYTES: Uint8Array = encoder.encode(SSE_DONE_FRAME);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { WireError } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
/** A wire-format failure carrying its structured payload through the stack. */
|
|
4
|
+
export class WireErrorException extends Error {
|
|
5
|
+
readonly wireError: WireError;
|
|
6
|
+
|
|
7
|
+
constructor(wireError: WireError) {
|
|
8
|
+
super(wireError.message);
|
|
9
|
+
this.name = "WireErrorException";
|
|
10
|
+
this.wireError = wireError;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function invalidRequest(message: string): WireErrorException {
|
|
15
|
+
return new WireErrorException({ status: 400, code: "invalid_request", message });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function modelNotFound(model: string): WireErrorException {
|
|
19
|
+
return new WireErrorException({
|
|
20
|
+
status: 404,
|
|
21
|
+
code: "model_not_found",
|
|
22
|
+
message: `Unknown model: ${model}`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function unauthorized(message = "Missing or invalid API key"): WireErrorException {
|
|
27
|
+
return new WireErrorException({ status: 401, code: "unauthorized", message });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Render a WireError in the OpenAI error envelope shape. */
|
|
31
|
+
export function renderErrorEnvelope(err: WireError): {
|
|
32
|
+
error: { message: string; type: string; code: string };
|
|
33
|
+
} {
|
|
34
|
+
return {
|
|
35
|
+
error: {
|
|
36
|
+
message: err.message,
|
|
37
|
+
type:
|
|
38
|
+
err.status === 401
|
|
39
|
+
? "authentication_error"
|
|
40
|
+
: err.status === 429
|
|
41
|
+
? "rate_limit_error"
|
|
42
|
+
: err.status >= 500
|
|
43
|
+
? "server_error"
|
|
44
|
+
: "invalid_request_error",
|
|
45
|
+
code: err.code,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RouterConfig } from "../../config/types.ts";
|
|
2
|
+
import type { BlendedRate } from "../../cost/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Virtual profiles have no real creation date; a pinned constant keeps
|
|
6
|
+
* /v1/models output stable for client caches.
|
|
7
|
+
*/
|
|
8
|
+
const PROFILES_CREATED = 1767225600; // 2026-01-01T00:00:00Z
|
|
9
|
+
|
|
10
|
+
export function renderModelList(cfg: RouterConfig, blend: BlendedRate | null): unknown {
|
|
11
|
+
const inputPerMtok = blend ? blend.inputPerMtok : cfg.ledger.fallbackBlend.inputPerMtok;
|
|
12
|
+
const outputPerMtok = blend ? blend.outputPerMtok : cfg.ledger.fallbackBlend.outputPerMtok;
|
|
13
|
+
return {
|
|
14
|
+
object: "list",
|
|
15
|
+
data: cfg.profiles.map((p) => {
|
|
16
|
+
// OpenRouter-style per-token price strings, converted from our
|
|
17
|
+
// per-million-token internal representation.
|
|
18
|
+
const pricing: Record<string, string> = {
|
|
19
|
+
prompt: String(inputPerMtok / 1e6),
|
|
20
|
+
completion: String(outputPerMtok / 1e6),
|
|
21
|
+
};
|
|
22
|
+
if (blend) {
|
|
23
|
+
pricing.input_cache_read = String(blend.cacheReadPerMtok / 1e6);
|
|
24
|
+
pricing.input_cache_write = String(blend.cacheWritePerMtok / 1e6);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
id: p.id,
|
|
28
|
+
object: "model",
|
|
29
|
+
created: PROFILES_CREATED,
|
|
30
|
+
owned_by: "auto-model-router",
|
|
31
|
+
name: p.name,
|
|
32
|
+
context_length: p.contextWindow,
|
|
33
|
+
pricing,
|
|
34
|
+
};
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
}
|