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,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter catalog: normalization of the raw `/api/v1/models` payload and
|
|
3
|
+
* a cached `CatalogSource` over it.
|
|
4
|
+
*
|
|
5
|
+
* Normalization is deliberately strict about money fields and lenient about
|
|
6
|
+
* everything else: a record whose price is unknowable (`"-1"`, dynamic
|
|
7
|
+
* routers like `openrouter/auto`) is dropped entirely, because routing on an
|
|
8
|
+
* unknown price is how budgets silently blow up.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
13
|
+
import type { UpstreamClient } from "../upstream/types.ts";
|
|
14
|
+
import { createLogger } from "../util/log.ts";
|
|
15
|
+
import type { CatalogModel, CatalogSnapshot, CatalogSource, Modality, Price, PriceTier, QualityScores } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isModality(value: unknown): value is Modality {
|
|
22
|
+
return value === "text" || value === "image" || value === "file" || value === "audio";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* OpenRouter prices are decimal strings, USD per token. A negative value
|
|
27
|
+
* means "unknown/dynamic" (their own meta-routers), NOT free — return null
|
|
28
|
+
* for both missing/unparseable and negative, and let the caller decide
|
|
29
|
+
* whether the component is mandatory.
|
|
30
|
+
*/
|
|
31
|
+
function parsePrice(value: unknown): number | null {
|
|
32
|
+
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
33
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
34
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when every numeric component the record publishes is exactly zero. */
|
|
38
|
+
function allComponentsZero(record: Record<string, unknown>): boolean {
|
|
39
|
+
for (const [key, value] of Object.entries(record)) {
|
|
40
|
+
if (key === "overrides" || key === "discount") continue;
|
|
41
|
+
if (typeof value !== "string" && typeof value !== "number") continue;
|
|
42
|
+
const n = parsePrice(value);
|
|
43
|
+
// An unparseable or negative component means "unknown", and unknown is not free.
|
|
44
|
+
if (n === null || n !== 0) return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tierComponentsZero(price: Price): boolean {
|
|
50
|
+
return (
|
|
51
|
+
price.prompt === 0 &&
|
|
52
|
+
price.completion === 0 &&
|
|
53
|
+
(price.cacheRead ?? 0) === 0 &&
|
|
54
|
+
(price.cacheWrite ?? 0) === 0 &&
|
|
55
|
+
(price.reasoning ?? 0) === 0 &&
|
|
56
|
+
(price.image ?? 0) === 0 &&
|
|
57
|
+
(price.request ?? 0) === 0
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Long-context override entries inherit any component they omit from the base price. */
|
|
62
|
+
function buildTierPrice(record: Record<string, unknown>, base: Price): Price {
|
|
63
|
+
const price: Price = {
|
|
64
|
+
prompt: parsePrice(record.prompt) ?? base.prompt,
|
|
65
|
+
completion: parsePrice(record.completion) ?? base.completion,
|
|
66
|
+
};
|
|
67
|
+
const cacheRead = parsePrice(record.input_cache_read) ?? base.cacheRead;
|
|
68
|
+
if (cacheRead !== undefined) price.cacheRead = cacheRead;
|
|
69
|
+
const cacheWrite = parsePrice(record.input_cache_write) ?? base.cacheWrite;
|
|
70
|
+
if (cacheWrite !== undefined) price.cacheWrite = cacheWrite;
|
|
71
|
+
const reasoning = parsePrice(record.internal_reasoning) ?? base.reasoning;
|
|
72
|
+
if (reasoning !== undefined) price.reasoning = reasoning;
|
|
73
|
+
const image = parsePrice(record.image) ?? base.image;
|
|
74
|
+
if (image !== undefined) price.image = image;
|
|
75
|
+
const request = parsePrice(record.request) ?? base.request;
|
|
76
|
+
if (request !== undefined) price.request = request;
|
|
77
|
+
return price;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeCatalogModel(raw: unknown): CatalogModel | null {
|
|
81
|
+
const record = asRecord(raw);
|
|
82
|
+
if (record === null) return null;
|
|
83
|
+
|
|
84
|
+
const id = record.id;
|
|
85
|
+
if (typeof id !== "string" || id.length === 0) return null;
|
|
86
|
+
|
|
87
|
+
const pricing = asRecord(record.pricing);
|
|
88
|
+
if (pricing === null) return null;
|
|
89
|
+
const prompt = parsePrice(pricing.prompt);
|
|
90
|
+
const completion = parsePrice(pricing.completion);
|
|
91
|
+
// Negative or missing prompt/completion price ⇒ cost is unknowable ⇒ unusable.
|
|
92
|
+
if (prompt === null || completion === null) return null;
|
|
93
|
+
|
|
94
|
+
const contextLength = record.context_length;
|
|
95
|
+
if (typeof contextLength !== "number" || !Number.isFinite(contextLength) || contextLength <= 0) return null;
|
|
96
|
+
|
|
97
|
+
const price: Price = { prompt, completion };
|
|
98
|
+
const cacheRead = parsePrice(pricing.input_cache_read);
|
|
99
|
+
if (cacheRead !== null) price.cacheRead = cacheRead;
|
|
100
|
+
const cacheWrite = parsePrice(pricing.input_cache_write);
|
|
101
|
+
if (cacheWrite !== null) price.cacheWrite = cacheWrite;
|
|
102
|
+
const reasoningPrice = parsePrice(pricing.internal_reasoning);
|
|
103
|
+
if (reasoningPrice !== null) price.reasoning = reasoningPrice;
|
|
104
|
+
const image = parsePrice(pricing.image);
|
|
105
|
+
if (image !== null) price.image = image;
|
|
106
|
+
const request = parsePrice(pricing.request);
|
|
107
|
+
if (request !== null) price.request = request;
|
|
108
|
+
|
|
109
|
+
// `pricing.overrides[]` mixes two kinds: token-tier entries keyed by
|
|
110
|
+
// `min_prompt_tokens` (long-context surcharges) and `utc_start`/`utc_end`
|
|
111
|
+
// time windows. PriceTier models only the token axis; time windows cannot
|
|
112
|
+
// be routed around and are skipped.
|
|
113
|
+
const priceTiers: PriceTier[] = [];
|
|
114
|
+
const overrides = Array.isArray(pricing.overrides) ? pricing.overrides : [];
|
|
115
|
+
for (const rawTier of overrides) {
|
|
116
|
+
const tierRecord = asRecord(rawTier);
|
|
117
|
+
if (tierRecord === null) continue;
|
|
118
|
+
const minPromptTokens = tierRecord.min_prompt_tokens;
|
|
119
|
+
if (typeof minPromptTokens !== "number" || !Number.isFinite(minPromptTokens) || minPromptTokens < 0) continue;
|
|
120
|
+
priceTiers.push({ minPromptTokens, price: buildTierPrice(tierRecord, price) });
|
|
121
|
+
}
|
|
122
|
+
priceTiers.sort((a, b) => a.minPromptTokens - b.minPromptTokens);
|
|
123
|
+
|
|
124
|
+
// Free means every published component is zero at every context length;
|
|
125
|
+
// `openrouter/auto` never reaches here (unknown price dropped above).
|
|
126
|
+
const isFree = allComponentsZero(pricing) && priceTiers.every((tier) => tierComponentsZero(tier.price));
|
|
127
|
+
|
|
128
|
+
const params = Array.isArray(record.supported_parameters) ? record.supported_parameters : [];
|
|
129
|
+
const supported: string[] = [];
|
|
130
|
+
for (const p of params) if (typeof p === "string") supported.push(p);
|
|
131
|
+
|
|
132
|
+
const architecture = asRecord(record.architecture);
|
|
133
|
+
const inputModalities: Modality[] = [];
|
|
134
|
+
const rawModalities = architecture !== null && Array.isArray(architecture.input_modalities) ? architecture.input_modalities : [];
|
|
135
|
+
for (const m of rawModalities) if (isModality(m) && !inputModalities.includes(m)) inputModalities.push(m);
|
|
136
|
+
if (inputModalities.length === 0) inputModalities.push("text");
|
|
137
|
+
const tokenizerRaw = architecture === null ? null : architecture.tokenizer;
|
|
138
|
+
const tokenizer = typeof tokenizerRaw === "string" && tokenizerRaw.length > 0 ? tokenizerRaw : "Other";
|
|
139
|
+
|
|
140
|
+
// Absent axes are omitted, never zero-filled: an unscored model must not
|
|
141
|
+
// satisfy a quality floor (router treats "absent on every axis" as unscored).
|
|
142
|
+
const quality: QualityScores = {};
|
|
143
|
+
const benchmarks = asRecord(record.benchmarks);
|
|
144
|
+
const aa = benchmarks === null ? null : asRecord(benchmarks.artificial_analysis);
|
|
145
|
+
if (aa !== null) {
|
|
146
|
+
const intelligence = aa.intelligence_index;
|
|
147
|
+
if (typeof intelligence === "number" && Number.isFinite(intelligence)) quality.intelligence = intelligence;
|
|
148
|
+
const coding = aa.coding_index;
|
|
149
|
+
if (typeof coding === "number" && Number.isFinite(coding)) quality.coding = coding;
|
|
150
|
+
const agentic = aa.agentic_index;
|
|
151
|
+
if (typeof agentic === "number" && Number.isFinite(agentic)) quality.agentic = agentic;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const reasoningMeta = asRecord(record.reasoning);
|
|
155
|
+
const topProvider = asRecord(record.top_provider);
|
|
156
|
+
const created = record.created;
|
|
157
|
+
const canonical = record.canonical_slug;
|
|
158
|
+
const name = record.name;
|
|
159
|
+
// `~vendor/model` slugs are floating aliases; the tilde is not part of the namespace.
|
|
160
|
+
const bare = id.startsWith("~") ? id.slice(1) : id;
|
|
161
|
+
const slash = bare.indexOf("/");
|
|
162
|
+
|
|
163
|
+
const model: CatalogModel = {
|
|
164
|
+
slug: id,
|
|
165
|
+
canonicalSlug: typeof canonical === "string" && canonical.length > 0 ? canonical : id,
|
|
166
|
+
name: typeof name === "string" && name.length > 0 ? name : id,
|
|
167
|
+
contextLength,
|
|
168
|
+
supportsTools: supported.includes("tools"),
|
|
169
|
+
supportsReasoning: supported.includes("reasoning") || supported.includes("include_reasoning"),
|
|
170
|
+
reasoningMandatory: reasoningMeta !== null && reasoningMeta.mandatory === true,
|
|
171
|
+
supportsToolChoice: supported.includes("tool_choice"),
|
|
172
|
+
inputModalities,
|
|
173
|
+
price,
|
|
174
|
+
priceTiers,
|
|
175
|
+
quality,
|
|
176
|
+
tokenizer,
|
|
177
|
+
isFree,
|
|
178
|
+
createdAtMs: typeof created === "number" && Number.isFinite(created) ? created * 1000 : 0,
|
|
179
|
+
author: slash === -1 ? bare : bare.slice(0, slash),
|
|
180
|
+
};
|
|
181
|
+
if (topProvider !== null) {
|
|
182
|
+
const maxCompletion = topProvider.max_completion_tokens;
|
|
183
|
+
if (typeof maxCompletion === "number" && Number.isFinite(maxCompletion) && maxCompletion > 0) {
|
|
184
|
+
model.maxCompletionTokens = maxCompletion;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return model;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface CacheRow {
|
|
191
|
+
payload: string;
|
|
192
|
+
fetched_at_ms: number;
|
|
193
|
+
etag: string | null;
|
|
194
|
+
key_scoped: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeAll(raw: unknown[]): CatalogModel[] {
|
|
198
|
+
const models: CatalogModel[] = [];
|
|
199
|
+
for (const record of raw) {
|
|
200
|
+
const model = normalizeCatalogModel(record);
|
|
201
|
+
if (model !== null) models.push(model);
|
|
202
|
+
}
|
|
203
|
+
return models;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Joins the AA `benchmarks` block from the public catalog onto key-scoped
|
|
208
|
+
* records.
|
|
209
|
+
*
|
|
210
|
+
* `GET /models/user` is authoritative for AVAILABILITY under the key's
|
|
211
|
+
* guardrails, but its payload omits `benchmarks` entirely — every record comes
|
|
212
|
+
* back unscored. An unscored model satisfies no quality floor above zero, so
|
|
213
|
+
* with only the key-scoped payload every tier except `trivial` (floor 0) is
|
|
214
|
+
* permanently empty and all traffic collapses onto the cheapest models. Join
|
|
215
|
+
* the public scores back on by `id`, falling back to `canonical_slug` so alias
|
|
216
|
+
* entries (`~vendor/model-latest`) inherit their target's scores.
|
|
217
|
+
*
|
|
218
|
+
* Returns the number of records that gained scores.
|
|
219
|
+
*/
|
|
220
|
+
export function joinBenchmarks(keyScoped: unknown[], publicRaw: unknown[]): number {
|
|
221
|
+
const byId = new Map<string, unknown>();
|
|
222
|
+
for (const record of publicRaw) {
|
|
223
|
+
const rec = asRecord(record);
|
|
224
|
+
if (rec === null) continue;
|
|
225
|
+
const benchmarks = rec.benchmarks;
|
|
226
|
+
if (benchmarks === undefined || benchmarks === null) continue;
|
|
227
|
+
if (typeof rec.id === "string") byId.set(rec.id, benchmarks);
|
|
228
|
+
// Only fill a canonical_slug key when nothing claimed it, so a real id
|
|
229
|
+
// always beats an alias target.
|
|
230
|
+
if (typeof rec.canonical_slug === "string" && !byId.has(rec.canonical_slug)) {
|
|
231
|
+
byId.set(rec.canonical_slug, benchmarks);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let joined = 0;
|
|
236
|
+
for (const record of keyScoped) {
|
|
237
|
+
const rec = asRecord(record);
|
|
238
|
+
if (rec === null) continue;
|
|
239
|
+
if (rec.benchmarks !== undefined && rec.benchmarks !== null) continue;
|
|
240
|
+
const id = typeof rec.id === "string" ? rec.id : null;
|
|
241
|
+
const canonical = typeof rec.canonical_slug === "string" ? rec.canonical_slug : null;
|
|
242
|
+
// An alias id keeps a leading `~`; strip it before the canonical lookup.
|
|
243
|
+
const stripped = id !== null && id.startsWith("~") ? id.slice(1) : null;
|
|
244
|
+
const benchmarks =
|
|
245
|
+
(id !== null ? byId.get(id) : undefined) ??
|
|
246
|
+
(canonical !== null ? byId.get(canonical) : undefined) ??
|
|
247
|
+
(stripped !== null ? byId.get(stripped) : undefined);
|
|
248
|
+
if (benchmarks === undefined) continue;
|
|
249
|
+
rec.benchmarks = benchmarks;
|
|
250
|
+
joined += 1;
|
|
251
|
+
}
|
|
252
|
+
return joined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: Database): CatalogSource {
|
|
256
|
+
const log = createLogger(cfg.logLevel);
|
|
257
|
+
let snapshot: CatalogSnapshot | null = null;
|
|
258
|
+
let bySlug = new Map<string, CatalogModel>();
|
|
259
|
+
let hydrated = false;
|
|
260
|
+
let inflight: Promise<CatalogSnapshot> | null = null;
|
|
261
|
+
const readCache = db.query("SELECT payload, fetched_at_ms, etag, key_scoped FROM catalog_cache WHERE id = 1");
|
|
262
|
+
const writeCache = db.query(
|
|
263
|
+
`INSERT INTO catalog_cache (id, payload, fetched_at_ms, etag, key_scoped) VALUES (1, ?, ?, NULL, ?)
|
|
264
|
+
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, fetched_at_ms = excluded.fetched_at_ms, etag = NULL, key_scoped = excluded.key_scoped`,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
function install(
|
|
268
|
+
models: CatalogModel[],
|
|
269
|
+
fetchedAtMs: number,
|
|
270
|
+
etag: string | null,
|
|
271
|
+
keyScoped = false,
|
|
272
|
+
): CatalogSnapshot {
|
|
273
|
+
const next: CatalogSnapshot = { models, fetchedAtMs, keyScoped };
|
|
274
|
+
if (etag !== null) next.etag = etag;
|
|
275
|
+
snapshot = next;
|
|
276
|
+
const map = new Map<string, CatalogModel>();
|
|
277
|
+
for (const model of models) map.set(model.slug, model);
|
|
278
|
+
bySlug = map;
|
|
279
|
+
return next;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Memory first; hydrate once from `catalog_cache` so restarts route from disk. */
|
|
283
|
+
function hydrate(): CatalogSnapshot | null {
|
|
284
|
+
if (snapshot !== null || hydrated) return snapshot;
|
|
285
|
+
hydrated = true;
|
|
286
|
+
// We own the schema; the row shape is fixed by util/sqlite.ts.
|
|
287
|
+
const row = readCache.get() as CacheRow | null;
|
|
288
|
+
if (row === null) return null;
|
|
289
|
+
try {
|
|
290
|
+
const payload: unknown = JSON.parse(row.payload);
|
|
291
|
+
if (!Array.isArray(payload)) return null;
|
|
292
|
+
// A key-scoped payload written before benchmarks were joined on has no
|
|
293
|
+
// scores at all, which silently empties every tier above `trivial`.
|
|
294
|
+
// Treat that as a stale cache and force a network refresh rather than
|
|
295
|
+
// booting into the broken state for a whole refresh interval.
|
|
296
|
+
if (row.key_scoped === 1 && payload.length > 0) {
|
|
297
|
+
const anyScored = payload.some((record) => {
|
|
298
|
+
const rec = asRecord(record);
|
|
299
|
+
return rec !== null && rec.benchmarks !== undefined && rec.benchmarks !== null;
|
|
300
|
+
});
|
|
301
|
+
if (!anyScored) {
|
|
302
|
+
log.warn("cached key-scoped catalog carries no benchmarks; refetching to restore tier floors");
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Provenance is persisted, not inferred from the current key: a payload
|
|
307
|
+
// written by a keyless run or the public fallback must not be advertised
|
|
308
|
+
// as key-scoped.
|
|
309
|
+
return install(normalizeAll(payload), row.fetched_at_ms, row.etag, row.key_scoped === 1);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
log.warn("catalog cache unreadable; treating as empty", { error: String(err) });
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function doRefresh(): Promise<CatalogSnapshot> {
|
|
317
|
+
let raw: unknown[];
|
|
318
|
+
let keyScoped = false;
|
|
319
|
+
|
|
320
|
+
if (cfg.openrouter.apiKey !== "") {
|
|
321
|
+
try {
|
|
322
|
+
raw = await upstream.fetchModelsForUser();
|
|
323
|
+
keyScoped = true;
|
|
324
|
+
log.debug("fetched key-scoped model catalog", { models: raw.length });
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// 401/403: auth failed on this key. Re-throw so callers do NOT silently
|
|
327
|
+
// fall back to public catalog models that this key cannot run.
|
|
328
|
+
if (err instanceof Error && "status" in err && (err.status === 401 || err.status === 403)) {
|
|
329
|
+
log.error("key-scoped catalog fetch unauthorized; rejecting refresh", {
|
|
330
|
+
status: err.status,
|
|
331
|
+
message: err.message,
|
|
332
|
+
});
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
log.warn("key-scoped catalog fetch failed; falling back to public /models", {
|
|
336
|
+
error: err instanceof Error ? err.message : String(err),
|
|
337
|
+
});
|
|
338
|
+
raw = await upstream.fetchModels();
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
raw = await upstream.fetchModels();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The key-scoped payload carries no `benchmarks`, which would leave every
|
|
345
|
+
// model unscored and every tier above `trivial` empty. Fetch the public
|
|
346
|
+
// catalog purely for scores and join them on. Best-effort: if the public
|
|
347
|
+
// fetch fails we route over an unscored catalog (degraded but working)
|
|
348
|
+
// rather than failing a refresh that already has the availability list.
|
|
349
|
+
if (keyScoped) {
|
|
350
|
+
try {
|
|
351
|
+
const publicRaw = await upstream.fetchModels();
|
|
352
|
+
const joined = joinBenchmarks(raw, publicRaw);
|
|
353
|
+
log.debug("joined public benchmarks onto key-scoped catalog", {
|
|
354
|
+
models: raw.length,
|
|
355
|
+
scored: joined,
|
|
356
|
+
});
|
|
357
|
+
if (joined === 0) {
|
|
358
|
+
log.warn("no key-scoped model matched a public benchmark record; tiers above trivial will be empty", {
|
|
359
|
+
models: raw.length,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
log.warn("public benchmark fetch failed; catalog stays unscored", {
|
|
364
|
+
error: err instanceof Error ? err.message : String(err),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const models = normalizeAll(raw);
|
|
370
|
+
// A guardrail can narrow the key-scoped list to zero usable models (or a
|
|
371
|
+
// transient upstream blip can return an empty payload). Treat that as a
|
|
372
|
+
// failed refresh: keep the previous snapshot rather than replacing a good
|
|
373
|
+
// one with an empty set that 500s every turn. `refresh()` has no stale
|
|
374
|
+
// fallback of its own, so return the snapshot directly here.
|
|
375
|
+
if (models.length === 0 && snapshot !== null) {
|
|
376
|
+
log.warn("catalog refresh returned no usable models; keeping the previous snapshot");
|
|
377
|
+
return snapshot;
|
|
378
|
+
}
|
|
379
|
+
const fetchedAtMs = Date.now();
|
|
380
|
+
// Persist the RAW payload: normalization improvements apply on the next
|
|
381
|
+
// boot without a network fetch. The client returns no headers, so no etag.
|
|
382
|
+
// Only persist the public fallback when keyless: a key-scoped run that fell
|
|
383
|
+
// back to public must not overwrite the key-scoped snapshot on disk, or a
|
|
384
|
+
// restart would route over the un-scoped catalog for up to catalogTtlMs.
|
|
385
|
+
if (cfg.openrouter.apiKey === "" || keyScoped) {
|
|
386
|
+
writeCache.run(JSON.stringify(raw), fetchedAtMs, keyScoped ? 1 : 0);
|
|
387
|
+
}
|
|
388
|
+
return install(models, fetchedAtMs, null, keyScoped);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Concurrent refreshes share one in-flight fetch. */
|
|
392
|
+
function refreshShared(): Promise<CatalogSnapshot> {
|
|
393
|
+
inflight ??= doRefresh().finally(() => {
|
|
394
|
+
inflight = null;
|
|
395
|
+
});
|
|
396
|
+
return inflight;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
async get(): Promise<CatalogSnapshot> {
|
|
401
|
+
const cached = hydrate();
|
|
402
|
+
if (cached !== null && Date.now() - cached.fetchedAtMs < cfg.openrouter.catalogTtlMs) return cached;
|
|
403
|
+
try {
|
|
404
|
+
return await refreshShared();
|
|
405
|
+
} catch (err) {
|
|
406
|
+
// A transient OpenRouter blip must never break routing.
|
|
407
|
+
if (cached !== null) {
|
|
408
|
+
log.warn("catalog refresh failed; serving stale snapshot", {
|
|
409
|
+
error: String(err),
|
|
410
|
+
fetchedAtMs: cached.fetchedAtMs,
|
|
411
|
+
});
|
|
412
|
+
return cached;
|
|
413
|
+
}
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
refresh(): Promise<CatalogSnapshot> {
|
|
418
|
+
return refreshShared();
|
|
419
|
+
},
|
|
420
|
+
peek(): CatalogSnapshot | null {
|
|
421
|
+
return hydrate();
|
|
422
|
+
},
|
|
423
|
+
find(slug: string): CatalogModel | undefined {
|
|
424
|
+
hydrate();
|
|
425
|
+
return bySlug.get(slug);
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized view of the OpenRouter model catalog.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth: `GET https://openrouter.ai/api/v1/models` (public, no auth).
|
|
5
|
+
* Everything here is derived from that payload; nothing is hand-curated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** USD **per token** (OpenRouter reports per-token decimal strings). */
|
|
9
|
+
export interface Price {
|
|
10
|
+
/** Uncached prompt tokens. */
|
|
11
|
+
prompt: number;
|
|
12
|
+
/** Completion tokens. */
|
|
13
|
+
completion: number;
|
|
14
|
+
/** Prompt tokens served from cache. Absent ⇒ no cache-read discount published. */
|
|
15
|
+
cacheRead?: number;
|
|
16
|
+
/** Prompt tokens written to cache. Absent ⇒ writes are free or unpublished. */
|
|
17
|
+
cacheWrite?: number;
|
|
18
|
+
/** Internal reasoning tokens billed separately from completion. */
|
|
19
|
+
reasoning?: number;
|
|
20
|
+
/** Per-image surcharge (USD per image, not per token). */
|
|
21
|
+
image?: number;
|
|
22
|
+
/** Flat per-request surcharge (USD). */
|
|
23
|
+
request?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A long-context pricing tier from `pricing.overrides[]`.
|
|
28
|
+
*
|
|
29
|
+
* Anthropic and Google roughly double their rates above a prompt-token
|
|
30
|
+
* threshold (Sonnet 4.5: 2x above 200k). Ignoring these silently understates
|
|
31
|
+
* long-conversation cost by ~50%, which is exactly when routing matters most.
|
|
32
|
+
*/
|
|
33
|
+
export interface PriceTier {
|
|
34
|
+
/** Tier applies when prompt tokens >= this value. */
|
|
35
|
+
minPromptTokens: number;
|
|
36
|
+
price: Price;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Modality = "text" | "image" | "file" | "audio";
|
|
40
|
+
|
|
41
|
+
/** Quality axes lifted from `benchmarks.artificial_analysis`. 0-100 scale, may be absent. */
|
|
42
|
+
export interface QualityScores {
|
|
43
|
+
intelligence?: number;
|
|
44
|
+
coding?: number;
|
|
45
|
+
agentic?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CatalogModel {
|
|
49
|
+
/** OpenRouter slug, e.g. `anthropic/claude-sonnet-4.5`. Routing identity. */
|
|
50
|
+
slug: string;
|
|
51
|
+
/** Immutable dated slug, e.g. `anthropic/claude-4.5-sonnet-20250929`. */
|
|
52
|
+
canonicalSlug: string;
|
|
53
|
+
name: string;
|
|
54
|
+
/** Max total context in tokens. */
|
|
55
|
+
contextLength: number;
|
|
56
|
+
/** `top_provider.max_completion_tokens`; absent ⇒ unpublished. */
|
|
57
|
+
maxCompletionTokens?: number;
|
|
58
|
+
/** `supported_parameters` includes `tools`. Hard filter for agent traffic. */
|
|
59
|
+
supportsTools: boolean;
|
|
60
|
+
/** `supported_parameters` includes `reasoning` or `include_reasoning`. */
|
|
61
|
+
supportsReasoning: boolean;
|
|
62
|
+
/** `reasoning.mandatory` — cannot be disabled, so reasoning tokens always bill. */
|
|
63
|
+
reasoningMandatory: boolean;
|
|
64
|
+
/** `supported_parameters` includes `tool_choice`. */
|
|
65
|
+
supportsToolChoice: boolean;
|
|
66
|
+
inputModalities: Modality[];
|
|
67
|
+
/** Base pricing tier (prompt tokens below every override threshold). */
|
|
68
|
+
price: Price;
|
|
69
|
+
/** Override tiers, ascending by `minPromptTokens`. Empty for flat-priced models. */
|
|
70
|
+
priceTiers: PriceTier[];
|
|
71
|
+
quality: QualityScores;
|
|
72
|
+
/** `architecture.tokenizer`, e.g. `Claude`, `GPT`, `Gemini`. Keys token-estimate calibration. */
|
|
73
|
+
tokenizer: string;
|
|
74
|
+
/** Every published price component is zero. */
|
|
75
|
+
isFree: boolean;
|
|
76
|
+
/** `created` as epoch ms; recency is a weak quality prior for unbenchmarked models. */
|
|
77
|
+
createdAtMs: number;
|
|
78
|
+
/** Provider namespace, i.e. the slug segment before `/`. */
|
|
79
|
+
author: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CatalogSnapshot {
|
|
83
|
+
models: CatalogModel[];
|
|
84
|
+
/** When this snapshot was fetched. */
|
|
85
|
+
fetchedAtMs: number;
|
|
86
|
+
/**
|
|
87
|
+
* True when the snapshot was fetched via key-scoped `GET /models/user`.
|
|
88
|
+
* False when fetched from the public `GET /models` endpoint.
|
|
89
|
+
*/
|
|
90
|
+
keyScoped?: boolean;
|
|
91
|
+
/** Upstream ETag, when served. */
|
|
92
|
+
etag?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CatalogSource {
|
|
96
|
+
/** Returns a snapshot, refreshing from upstream when the cached one is older than the TTL. */
|
|
97
|
+
get(): Promise<CatalogSnapshot>;
|
|
98
|
+
/** Forces an upstream refresh, ignoring TTL. */
|
|
99
|
+
refresh(): Promise<CatalogSnapshot>;
|
|
100
|
+
/** Cached snapshot without touching the network. `null` before first successful fetch. */
|
|
101
|
+
peek(): CatalogSnapshot | null;
|
|
102
|
+
/** Slug lookup against the current snapshot. */
|
|
103
|
+
find(slug: string): CatalogModel | undefined;
|
|
104
|
+
}
|
package/src/cli/args.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argv parsing for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from `src/index.ts` so command modules can import the helpers
|
|
5
|
+
* without a cycle back through the entry point.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
9
|
+
|
|
10
|
+
export interface CliArgs {
|
|
11
|
+
command: string;
|
|
12
|
+
positionals: string[];
|
|
13
|
+
/** Long flags. Value-less flags map to `true`. */
|
|
14
|
+
flags: Map<string, string | true>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Flags that never consume the following token. Without this, `--json` before
|
|
19
|
+
* a positional would silently swallow it -- the classic hand-rolled parser bug.
|
|
20
|
+
*/
|
|
21
|
+
const BOOLEAN_FLAGS: Record<string, true> = {
|
|
22
|
+
json: true,
|
|
23
|
+
write: true,
|
|
24
|
+
print: true,
|
|
25
|
+
help: true,
|
|
26
|
+
version: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const COMMANDS: Record<string, true> = {
|
|
30
|
+
stats: true,
|
|
31
|
+
models: true,
|
|
32
|
+
explain: true,
|
|
33
|
+
config: true,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function parseArgv(argv: string[]): CliArgs {
|
|
37
|
+
const flags = new Map<string, string | true>();
|
|
38
|
+
const positionals: string[] = [];
|
|
39
|
+
let command = "";
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const token = argv[i];
|
|
43
|
+
if (token === undefined) continue;
|
|
44
|
+
|
|
45
|
+
if (token === "-h") {
|
|
46
|
+
flags.set("help", true);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!token.startsWith("--")) {
|
|
50
|
+
if (command === "" && COMMANDS[token] === true) command = token;
|
|
51
|
+
else positionals.push(token);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const body = token.slice(2);
|
|
56
|
+
const eq = body.indexOf("=");
|
|
57
|
+
if (eq !== -1) {
|
|
58
|
+
flags.set(body.slice(0, eq), body.slice(eq + 1));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (BOOLEAN_FLAGS[body] === true) {
|
|
62
|
+
flags.set(body, true);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const next = argv[i + 1];
|
|
66
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
67
|
+
flags.set(body, next);
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
flags.set(body, true);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { command, positionals, flags };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function flagString(args: CliArgs, name: string): string | undefined {
|
|
78
|
+
const value = args.flags.get(name);
|
|
79
|
+
return typeof value === "string" ? value : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function flagInt(args: CliArgs, name: string): number | undefined {
|
|
83
|
+
const raw = flagString(args, name);
|
|
84
|
+
if (raw === undefined) return undefined;
|
|
85
|
+
const parsed = Number.parseInt(raw, 10);
|
|
86
|
+
if (!Number.isFinite(parsed)) throw new Error(`--${name} expects an integer, got "${raw}"`);
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Builds `loadConfig` options from the global `--config` flag.
|
|
92
|
+
*
|
|
93
|
+
* Keys are added only when present: `exactOptionalPropertyTypes` makes an
|
|
94
|
+
* explicit `undefined` a type error, not a no-op.
|
|
95
|
+
*/
|
|
96
|
+
export function configOpts(
|
|
97
|
+
args: CliArgs,
|
|
98
|
+
overrides?: Partial<RouterConfig>,
|
|
99
|
+
): { path?: string; overrides?: Partial<RouterConfig> } {
|
|
100
|
+
const opts: { path?: string; overrides?: Partial<RouterConfig> } = {};
|
|
101
|
+
const path = flagString(args, "config");
|
|
102
|
+
if (path !== undefined) opts.path = path;
|
|
103
|
+
if (overrides !== undefined) opts.overrides = overrides;
|
|
104
|
+
return opts;
|
|
105
|
+
}
|