auto-model-router 0.2.32 → 0.3.1
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/.omp-plugin/marketplace.json +2 -2
- package/README.md +225 -29
- package/docs/review-2026-09-05.md +267 -0
- package/omp-extension/configure-logic.ts +71 -15
- package/omp-extension/pi-coding-agent.d.ts +79 -2
- package/omp-extension/report-hub.ts +376 -0
- package/omp-extension/report-logic.ts +117 -0
- package/omp-extension/router-configure.ts +203 -51
- package/omp-extension/router-url.ts +52 -0
- package/omp-extension/toast-logic.ts +14 -2
- package/package.json +1 -1
- package/src/catalog/composite.ts +97 -0
- package/src/catalog/ollama-catalog.ts +309 -0
- package/src/catalog/ollama-prices.ts +85 -0
- package/src/catalog/openrouter-catalog.ts +39 -1
- package/src/catalog/types.ts +31 -1
- package/src/cli/args.ts +1 -0
- package/src/cli/config-wizard.ts +190 -28
- package/src/cli/explain.ts +2 -4
- package/src/cli/models.ts +2 -4
- package/src/cli/report.ts +37 -0
- package/src/config/defaults.ts +46 -2
- package/src/config/load.ts +25 -1
- package/src/config/omp-credentials.ts +31 -7
- package/src/config/schema.ts +28 -0
- package/src/config/types.ts +120 -2
- package/src/cost/cache-estimate.ts +52 -0
- package/src/cost/ledger.ts +73 -4
- package/src/cost/report.ts +351 -0
- package/src/cost/types.ts +39 -1
- package/src/index.ts +5 -8
- package/src/router/candidates.ts +52 -4
- package/src/router/classify.ts +33 -6
- package/src/router/features.ts +13 -1
- package/src/router/select.ts +55 -8
- package/src/router/state.ts +6 -2
- package/src/router/tier-plan.ts +49 -11
- package/src/router/types.ts +10 -0
- package/src/server/http.ts +50 -6
- package/src/server/providers.ts +54 -0
- package/src/server/turn.ts +138 -34
- package/src/tokens/estimate.ts +16 -0
- package/src/upstream/multi.ts +26 -0
- package/src/upstream/ollama-usage.ts +163 -0
- package/src/upstream/ollama.ts +275 -0
- package/src/upstream/openrouter.ts +19 -1
- package/src/upstream/types.ts +2 -0
- package/src/util/sqlite.ts +25 -1
- package/test/cache-estimate.test.ts +48 -0
- package/test/catalog.test.ts +44 -0
- package/test/classify.test.ts +41 -5
- package/test/compaction.test.ts +1 -0
- package/test/config-wizard.test.ts +77 -1
- package/test/configure-logic.test.ts +129 -33
- package/test/embed-lifecycle.test.ts +1 -0
- package/test/failover.test.ts +148 -3
- package/test/features.test.ts +35 -0
- package/test/http-resilience.test.ts +24 -0
- package/test/ollama.test.ts +521 -0
- package/test/omp-credentials.test.ts +43 -1
- package/test/report-hub.test.ts +343 -0
- package/test/report-logic.test.ts +93 -0
- package/test/report.test.ts +233 -0
- package/test/select.test.ts +151 -1
- package/test/tier-plan.test.ts +159 -1
- package/test/toast-logic.test.ts +11 -2
- package/test/tokens.test.ts +71 -1
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +173 -7
- package/tools/recompute-ollama-cache.ts +129 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama Cloud catalog: turns Ollama's model listing into `CatalogModel`s the
|
|
3
|
+
* router can rank next to OpenRouter's.
|
|
4
|
+
*
|
|
5
|
+
* Ollama publishes far less per model than OpenRouter does, so each model is
|
|
6
|
+
* assembled from three sources:
|
|
7
|
+
*
|
|
8
|
+
* 1. The listing itself (`GET /api/tags`, on the daemon or on ollama.com):
|
|
9
|
+
* ids, and on the daemon the context length + capabilities of every cloud
|
|
10
|
+
* model. ollama.com's listing carries neither, so there `POST /api/show`
|
|
11
|
+
* (unauthenticated) fills them, cached per id for the life of the process.
|
|
12
|
+
* 2. The price table (`ollama-prices.ts` + `ollama.prices` config), since no
|
|
13
|
+
* Ollama endpoint publishes rates. Unpriced models are dropped.
|
|
14
|
+
* 3. The OpenRouter twin — the same model under an OpenRouter slug, matched by
|
|
15
|
+
* normalised name (`glm-5.3-flash` ↔ `z-ai/glm-5.3-flash`) — for the
|
|
16
|
+
* quality scores that put the model above `trivial`, and as a fallback for
|
|
17
|
+
* tokenizer, context and capabilities. `ollama.twins` in config pins a
|
|
18
|
+
* match the normaliser cannot make.
|
|
19
|
+
*
|
|
20
|
+
* Slugs are `ollama/<id as listed>`, so the daemon's `glm-5.3-flash:cloud` and
|
|
21
|
+
* ollama.com's `glm-5.3-flash` are distinct catalog entries with the same
|
|
22
|
+
* price and twin; the dispatch client strips the `ollama/` prefix.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { OllamaConfig } from "../config/types.ts";
|
|
26
|
+
import type { Logger } from "../util/log.ts";
|
|
27
|
+
import { normalizeModelKey } from "./benchmark-feeds.ts";
|
|
28
|
+
import { bareCloudName, ollamaRateFor, type OllamaRate } from "./ollama-prices.ts";
|
|
29
|
+
import type { CatalogModel, CatalogSnapshot, Modality } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
export const OLLAMA_SLUG_PREFIX = "ollama/";
|
|
32
|
+
|
|
33
|
+
/** One entry of Ollama's `/api/tags` listing, reduced to what routing needs. */
|
|
34
|
+
export interface OllamaListing {
|
|
35
|
+
id: string;
|
|
36
|
+
/** Bare cloud name on ollama.com (`remote_model` on the daemon). */
|
|
37
|
+
remoteModel: string | null;
|
|
38
|
+
/** Present when the daemon proxies this model to ollama.com. */
|
|
39
|
+
isCloud: boolean;
|
|
40
|
+
contextLength: number | null;
|
|
41
|
+
capabilities: string[];
|
|
42
|
+
modifiedAtMs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asRec(v: unknown): Record<string, unknown> | null {
|
|
46
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parses one raw `/api/tags` model record. */
|
|
50
|
+
export function parseOllamaListing(raw: unknown, source: "daemon" | "ollama.com"): OllamaListing | null {
|
|
51
|
+
const rec = asRec(raw);
|
|
52
|
+
if (rec === null) return null;
|
|
53
|
+
const id = typeof rec.model === "string" && rec.model !== "" ? rec.model : typeof rec.name === "string" ? rec.name : "";
|
|
54
|
+
if (id === "") return null;
|
|
55
|
+
const details = asRec(rec.details);
|
|
56
|
+
const ctx = details?.context_length;
|
|
57
|
+
const caps = Array.isArray(rec.capabilities) ? rec.capabilities.filter((c): c is string => typeof c === "string") : [];
|
|
58
|
+
const remote = typeof rec.remote_model === "string" && rec.remote_model !== "" ? rec.remote_model : null;
|
|
59
|
+
const modified = typeof rec.modified_at === "string" ? Date.parse(rec.modified_at) : NaN;
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
remoteModel: remote ?? (source === "ollama.com" ? id : null),
|
|
63
|
+
// On the daemon only proxied entries are cloud models; on ollama.com
|
|
64
|
+
// everything listed is.
|
|
65
|
+
isCloud: source === "ollama.com" || typeof rec.remote_host === "string",
|
|
66
|
+
contextLength: typeof ctx === "number" && Number.isFinite(ctx) && ctx > 0 ? ctx : null,
|
|
67
|
+
capabilities: caps,
|
|
68
|
+
modifiedAtMs: Number.isFinite(modified) ? modified : 0,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `POST /api/show` reduced to the two fields the listing may lack. */
|
|
73
|
+
export function parseOllamaShow(raw: unknown): { contextLength: number | null; capabilities: string[] } {
|
|
74
|
+
const rec = asRec(raw);
|
|
75
|
+
const caps = rec !== null && Array.isArray(rec.capabilities) ? rec.capabilities.filter((c): c is string => typeof c === "string") : [];
|
|
76
|
+
let contextLength: number | null = null;
|
|
77
|
+
const info = rec === null ? null : asRec(rec.model_info);
|
|
78
|
+
if (info !== null) {
|
|
79
|
+
for (const [k, v] of Object.entries(info)) {
|
|
80
|
+
if (k.endsWith(".context_length") && typeof v === "number" && Number.isFinite(v) && v > 0) {
|
|
81
|
+
contextLength = v;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { contextLength, capabilities: caps };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalised match key for an Ollama name. Ollama separates the tag with `:`
|
|
91
|
+
* (`gpt-oss:120b`) where OpenRouter uses `-` (`openai/gpt-oss-120b`), so the
|
|
92
|
+
* tag is folded in before the shared normaliser runs.
|
|
93
|
+
*/
|
|
94
|
+
export function ollamaTwinKey(name: string): string {
|
|
95
|
+
return normalizeModelKey(bareCloudName(name).replace(/:/g, "-"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** OpenRouter models indexed by normalised name, first slug wins per key. */
|
|
99
|
+
export function twinIndex(openrouter: readonly CatalogModel[]): Map<string, CatalogModel> {
|
|
100
|
+
const out = new Map<string, CatalogModel>();
|
|
101
|
+
for (const m of openrouter) {
|
|
102
|
+
if (m.provider !== "openrouter") continue;
|
|
103
|
+
if (m.slug.startsWith("~") || m.slug.endsWith(":batch") || m.slug.includes(":free")) continue;
|
|
104
|
+
const key = normalizeModelKey(m.slug);
|
|
105
|
+
if (!out.has(key)) out.set(key, m);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BuildOllamaArgs {
|
|
111
|
+
listings: readonly OllamaListing[];
|
|
112
|
+
openrouter: readonly CatalogModel[];
|
|
113
|
+
cfg: OllamaConfig;
|
|
114
|
+
log?: Logger;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Builds catalog models from listings + prices + twins. Pure; no I/O. */
|
|
118
|
+
export function buildOllamaModels(args: BuildOllamaArgs): CatalogModel[] {
|
|
119
|
+
const { listings, openrouter, cfg, log } = args;
|
|
120
|
+
const twins = twinIndex(openrouter);
|
|
121
|
+
const bySlug = new Map(openrouter.map((m) => [m.slug, m] as const));
|
|
122
|
+
const out: CatalogModel[] = [];
|
|
123
|
+
const skipped: string[] = [];
|
|
124
|
+
for (const l of listings) {
|
|
125
|
+
if (!l.isCloud && !cfg.includeLocal) continue;
|
|
126
|
+
const priceName = l.remoteModel ?? l.id;
|
|
127
|
+
const rate = ollamaRateFor(priceName, cfg.prices);
|
|
128
|
+
if (rate === null) {
|
|
129
|
+
skipped.push(l.id);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A pin may name the tagged cloud name, its base, or the listed id.
|
|
133
|
+
const bare = bareCloudName(priceName);
|
|
134
|
+
const base = bare.includes(":") ? bare.slice(0, bare.indexOf(":")) : bare;
|
|
135
|
+
const pinned = cfg.twins[bare] ?? cfg.twins[base] ?? cfg.twins[l.id];
|
|
136
|
+
const twin = (pinned !== undefined ? bySlug.get(pinned) : undefined) ?? twins.get(ollamaTwinKey(priceName)) ?? null;
|
|
137
|
+
const contextLength = l.contextLength ?? twin?.contextLength ?? null;
|
|
138
|
+
if (contextLength === null) {
|
|
139
|
+
skipped.push(`${l.id} (no context length)`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const caps = new Set(l.capabilities);
|
|
143
|
+
const hasCaps = caps.size > 0;
|
|
144
|
+
const modalities: Modality[] = ["text"];
|
|
145
|
+
if (hasCaps ? caps.has("vision") : (twin?.inputModalities.includes("image") ?? false)) modalities.push("image");
|
|
146
|
+
const model: CatalogModel = {
|
|
147
|
+
slug: `${OLLAMA_SLUG_PREFIX}${l.id}`,
|
|
148
|
+
canonicalSlug: `${OLLAMA_SLUG_PREFIX}${priceName}`,
|
|
149
|
+
name: `${l.id} (Ollama Cloud)`,
|
|
150
|
+
provider: "ollama",
|
|
151
|
+
contextLength,
|
|
152
|
+
supportsTools: hasCaps ? caps.has("tools") : (twin?.supportsTools ?? false),
|
|
153
|
+
supportsReasoning: hasCaps ? caps.has("thinking") : (twin?.supportsReasoning ?? false),
|
|
154
|
+
reasoningMandatory: false,
|
|
155
|
+
// Ollama's OpenAI-compatible endpoint documents `tool_choice` as unsupported.
|
|
156
|
+
supportsToolChoice: false,
|
|
157
|
+
inputModalities: modalities,
|
|
158
|
+
price: toPrice(rate.rate),
|
|
159
|
+
priceTiers: [],
|
|
160
|
+
quality: twin === null ? {} : { ...twin.quality },
|
|
161
|
+
tokenizer: twin?.tokenizer ?? "Other",
|
|
162
|
+
isFree: false,
|
|
163
|
+
createdAtMs: l.modifiedAtMs,
|
|
164
|
+
author: "ollama",
|
|
165
|
+
};
|
|
166
|
+
if (twin?.maxCompletionTokens !== undefined) model.maxCompletionTokens = twin.maxCompletionTokens;
|
|
167
|
+
out.push(model);
|
|
168
|
+
}
|
|
169
|
+
if (skipped.length > 0) log?.debug("ollama models skipped (no price or context)", { skipped: skipped.join(", ") });
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toPrice(rate: OllamaRate): CatalogModel["price"] {
|
|
174
|
+
const price: CatalogModel["price"] = { prompt: rate.input / 1e6, completion: rate.output / 1e6 };
|
|
175
|
+
if (rate.cachedInput !== undefined) price.cacheRead = rate.cachedInput / 1e6;
|
|
176
|
+
return price;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Minimal fetch surface, injectable for tests. */
|
|
180
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
181
|
+
|
|
182
|
+
export interface OllamaCatalogSource {
|
|
183
|
+
/** Cloud models, refreshed when older than `cfg.catalogTtlMs`; last good set on failure. */
|
|
184
|
+
get(openrouter: readonly CatalogModel[]): Promise<CatalogModel[]>;
|
|
185
|
+
/** Last built set without touching the network. */
|
|
186
|
+
peek(): CatalogModel[];
|
|
187
|
+
/** Forces a re-list on the next `get`. */
|
|
188
|
+
invalidate(): void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether a base URL points at ollama.com (which needs `/api/show` for metadata). */
|
|
192
|
+
export function isOllamaDotCom(baseUrl: string): boolean {
|
|
193
|
+
try {
|
|
194
|
+
return new URL(baseUrl).hostname.toLowerCase().endsWith("ollama.com");
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `https://ollama.com/v1` → `https://ollama.com`; the native API lives beside `/v1`. */
|
|
201
|
+
export function ollamaApiRoot(baseUrl: string): string {
|
|
202
|
+
return baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function createOllamaCatalog(cfg: OllamaConfig, log: Logger, fetchImpl: FetchLike = fetch): OllamaCatalogSource {
|
|
206
|
+
const root = ollamaApiRoot(cfg.baseUrl);
|
|
207
|
+
const direct = isOllamaDotCom(cfg.baseUrl);
|
|
208
|
+
const headers: Record<string, string> = {};
|
|
209
|
+
if (cfg.apiKey !== "") headers.authorization = `Bearer ${cfg.apiKey}`;
|
|
210
|
+
let models: CatalogModel[] = [];
|
|
211
|
+
let listedAtMs = 0;
|
|
212
|
+
let inflight: Promise<CatalogModel[]> | null = null;
|
|
213
|
+
// `/api/show` results are stable per id; fetched once per process.
|
|
214
|
+
const shown = new Map<string, { contextLength: number | null; capabilities: string[] }>();
|
|
215
|
+
|
|
216
|
+
async function list(): Promise<OllamaListing[]> {
|
|
217
|
+
const res = await fetchImpl(`${root}/api/tags`, { headers, signal: AbortSignal.timeout(cfg.timeoutMs) });
|
|
218
|
+
if (!res.ok) throw new Error(`ollama /api/tags HTTP ${res.status}`);
|
|
219
|
+
const json = asRec(await res.json());
|
|
220
|
+
const raw = json !== null && Array.isArray(json.models) ? json.models : [];
|
|
221
|
+
const out: OllamaListing[] = [];
|
|
222
|
+
for (const r of raw) {
|
|
223
|
+
const l = parseOllamaListing(r, direct ? "ollama.com" : "daemon");
|
|
224
|
+
if (l !== null) out.push(l);
|
|
225
|
+
}
|
|
226
|
+
// ollama.com's listing has no context/capabilities; ask per model, once.
|
|
227
|
+
if (direct) {
|
|
228
|
+
for (const l of out) {
|
|
229
|
+
if (l.contextLength !== null && l.capabilities.length > 0) continue;
|
|
230
|
+
let s = shown.get(l.id);
|
|
231
|
+
if (s === undefined) {
|
|
232
|
+
try {
|
|
233
|
+
const r = await fetchImpl(`${root}/api/show`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
236
|
+
body: JSON.stringify({ model: l.id }),
|
|
237
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
238
|
+
});
|
|
239
|
+
s = r.ok ? parseOllamaShow(await r.json()) : { contextLength: null, capabilities: [] };
|
|
240
|
+
} catch {
|
|
241
|
+
s = { contextLength: null, capabilities: [] };
|
|
242
|
+
}
|
|
243
|
+
shown.set(l.id, s);
|
|
244
|
+
}
|
|
245
|
+
if (l.contextLength === null) l.contextLength = s.contextLength;
|
|
246
|
+
if (l.capabilities.length === 0) l.capabilities = s.capabilities;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function refresh(openrouter: readonly CatalogModel[]): Promise<CatalogModel[]> {
|
|
253
|
+
try {
|
|
254
|
+
const listings = await list();
|
|
255
|
+
const built = buildOllamaModels({ listings, openrouter, cfg, log });
|
|
256
|
+
if (built.length === 0 && models.length > 0) {
|
|
257
|
+
log.warn("ollama listing yielded no priced cloud models; keeping the previous set", { listed: listings.length });
|
|
258
|
+
} else {
|
|
259
|
+
models = built;
|
|
260
|
+
}
|
|
261
|
+
listedAtMs = Date.now();
|
|
262
|
+
} catch (err) {
|
|
263
|
+
log.warn("ollama catalog refresh failed; keeping the previous set", {
|
|
264
|
+
error: err instanceof Error ? err.message : String(err),
|
|
265
|
+
models: models.length,
|
|
266
|
+
});
|
|
267
|
+
// Back off for a full TTL rather than hammering a dead endpoint each turn.
|
|
268
|
+
listedAtMs = Date.now();
|
|
269
|
+
}
|
|
270
|
+
return models;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
async get(openrouter) {
|
|
275
|
+
if (Date.now() - listedAtMs < cfg.catalogTtlMs) return models;
|
|
276
|
+
inflight ??= refresh(openrouter).finally(() => {
|
|
277
|
+
inflight = null;
|
|
278
|
+
});
|
|
279
|
+
return inflight;
|
|
280
|
+
},
|
|
281
|
+
peek() {
|
|
282
|
+
return models;
|
|
283
|
+
},
|
|
284
|
+
invalidate() {
|
|
285
|
+
listedAtMs = 0;
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** True when a catalog slug is an Ollama model. */
|
|
291
|
+
export function isOllamaSlug(slug: string): boolean {
|
|
292
|
+
return slug.startsWith(OLLAMA_SLUG_PREFIX);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The id Ollama expects in the request body. */
|
|
296
|
+
export function ollamaModelId(slug: string): string {
|
|
297
|
+
return isOllamaSlug(slug) ? slug.slice(OLLAMA_SLUG_PREFIX.length) : slug;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Composite snapshot helper: append Ollama models to an OpenRouter snapshot. */
|
|
301
|
+
export function mergeSnapshots(openrouter: CatalogSnapshot, ollama: readonly CatalogModel[]): CatalogSnapshot {
|
|
302
|
+
if (ollama.length === 0) return openrouter;
|
|
303
|
+
const merged: CatalogSnapshot = {
|
|
304
|
+
...openrouter,
|
|
305
|
+
models: [...openrouter.models, ...ollama],
|
|
306
|
+
fetchedAtMs: openrouter.fetchedAtMs,
|
|
307
|
+
};
|
|
308
|
+
return merged;
|
|
309
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama Cloud per-model token rates, USD per million tokens.
|
|
3
|
+
*
|
|
4
|
+
* Ollama publishes these on https://ollama.com/pricing and nowhere machine-
|
|
5
|
+
* readable: neither `/v1/models` nor `/api/tags` carries a price, so the router
|
|
6
|
+
* ships a snapshot and lets `ollama.prices` in config override or extend it. A
|
|
7
|
+
* model with no rate from either source is dropped from the catalog — routing
|
|
8
|
+
* on an unknown price is how budgets silently blow up (same rule as the
|
|
9
|
+
* OpenRouter `-1` sentinel).
|
|
10
|
+
*
|
|
11
|
+
* Keys are the bare cloud model names as ollama.com lists them. Where Ollama
|
|
12
|
+
* prices a specific tag (`gpt-oss:120b`) the key keeps the tag; otherwise the
|
|
13
|
+
* base name covers every tag (`deepseek-v4-pro:0813` → `deepseek-v4-pro`).
|
|
14
|
+
*
|
|
15
|
+
* Snapshot taken 2026-09-05 from ollama.com/pricing. Cached-input rates are
|
|
16
|
+
* absent for some rows on that page; those models get no cache-read discount
|
|
17
|
+
* rather than an invented one.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface OllamaRate {
|
|
21
|
+
/** USD per million uncached prompt tokens. */
|
|
22
|
+
input: number;
|
|
23
|
+
/** USD per million cached prompt tokens. Absent ⇒ no published discount. */
|
|
24
|
+
cachedInput?: number;
|
|
25
|
+
/** USD per million completion tokens. */
|
|
26
|
+
output: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const OLLAMA_PRICE_SNAPSHOT_DATE = "2026-09-05";
|
|
30
|
+
|
|
31
|
+
export const OLLAMA_BUILTIN_PRICES: Readonly<Record<string, OllamaRate>> = {
|
|
32
|
+
"deepseek-v4-flash": { input: 0.22, cachedInput: 0.007, output: 0.66 },
|
|
33
|
+
"deepseek-v4-pro": { input: 0.66, cachedInput: 0.022, output: 1.98 },
|
|
34
|
+
gemma4: { input: 0.14, cachedInput: 0.05, output: 0.4 },
|
|
35
|
+
"glm-5.3": { input: 1.4, cachedInput: 0.26, output: 4.4 },
|
|
36
|
+
"glm-5.3-flash": { input: 0.15, cachedInput: 0.03, output: 0.5 },
|
|
37
|
+
"glm-5.2": { input: 1.4, cachedInput: 0.26, output: 4.4 },
|
|
38
|
+
"glm-5.1": { input: 1.0, cachedInput: 0.2, output: 3.2 },
|
|
39
|
+
"gpt-oss:120b": { input: 0.15, cachedInput: 0.014, output: 0.6 },
|
|
40
|
+
"gpt-oss:20b": { input: 0.07, cachedInput: 0.035, output: 0.3 },
|
|
41
|
+
"kimi-k3": { input: 3.0, cachedInput: 0.3, output: 15.0 },
|
|
42
|
+
"kimi-k2.7-code": { input: 0.95, cachedInput: 0.19, output: 4.0 },
|
|
43
|
+
"kimi-k2.6": { input: 0.95, cachedInput: 0.16, output: 4.0 },
|
|
44
|
+
"minimax-m3": { input: 0.6, cachedInput: 0.12, output: 2.4 },
|
|
45
|
+
"minimax-m2.7": { input: 0.3, cachedInput: 0.06, output: 1.2 },
|
|
46
|
+
"mistral-large-3": { input: 0.5, output: 1.5 },
|
|
47
|
+
"nemotron-3-nano": { input: 0.06, output: 0.24 },
|
|
48
|
+
"nemotron-3-super": { input: 0.015, cachedInput: 0.015, output: 0.6 },
|
|
49
|
+
"nemotron-3-ultra": { input: 0.1, cachedInput: 0.1, output: 3.0 },
|
|
50
|
+
"qwen3.5:397b": { input: 0.6, output: 3.6 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The bare cloud name a listing entry is priced under: the daemon's `:cloud`
|
|
55
|
+
* / `-cloud` decoration stripped, lower-cased. `glm-5.3-flash:cloud` and
|
|
56
|
+
* `deepseek-v4-pro:0813-cloud` become `glm-5.3-flash` and
|
|
57
|
+
* `deepseek-v4-pro:0813`, which is exactly how ollama.com lists them.
|
|
58
|
+
*/
|
|
59
|
+
export function bareCloudName(id: string): string {
|
|
60
|
+
let s = id.trim().toLowerCase();
|
|
61
|
+
if (s.endsWith(":cloud")) s = s.slice(0, -":cloud".length);
|
|
62
|
+
else if (s.endsWith("-cloud")) s = s.slice(0, -"-cloud".length);
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Rate for a cloud model: the exact tagged name first (`gpt-oss:120b`), then
|
|
68
|
+
* the base name before the tag (`deepseek-v4-pro:0813` → `deepseek-v4-pro`).
|
|
69
|
+
* `overrides` (from config) win over the built-in snapshot at every step.
|
|
70
|
+
*/
|
|
71
|
+
export function ollamaRateFor(
|
|
72
|
+
id: string,
|
|
73
|
+
overrides: Readonly<Record<string, OllamaRate>> = {},
|
|
74
|
+
): { key: string; rate: OllamaRate } | null {
|
|
75
|
+
const bare = bareCloudName(id);
|
|
76
|
+
const colon = bare.indexOf(":");
|
|
77
|
+
const candidates = colon === -1 ? [bare] : [bare, bare.slice(0, colon)];
|
|
78
|
+
for (const key of candidates) {
|
|
79
|
+
const o = overrides[key];
|
|
80
|
+
if (o !== undefined) return { key, rate: o };
|
|
81
|
+
const b = OLLAMA_BUILTIN_PRICES[key];
|
|
82
|
+
if (b !== undefined) return { key, rate: b };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -12,7 +12,25 @@ import type { Database } from "bun:sqlite";
|
|
|
12
12
|
import type { RouterConfig } from "../config/types.ts";
|
|
13
13
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
14
14
|
import { createLogger } from "../util/log.ts";
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
CatalogModel,
|
|
17
|
+
CatalogShrink,
|
|
18
|
+
CatalogSnapshot,
|
|
19
|
+
CatalogSource,
|
|
20
|
+
Modality,
|
|
21
|
+
Price,
|
|
22
|
+
PriceTier,
|
|
23
|
+
QualityScores,
|
|
24
|
+
} from "./types.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A refresh that keeps fewer than this fraction of the previous snapshot's
|
|
28
|
+
* models is a "sharp shrink": logged at warn and surfaced on /health. Only
|
|
29
|
+
* judged once the previous snapshot is big enough for the ratio to mean
|
|
30
|
+
* anything (SHRINK_MIN_PREVIOUS).
|
|
31
|
+
*/
|
|
32
|
+
const SHRINK_KEEP_RATIO = 0.5;
|
|
33
|
+
const SHRINK_MIN_PREVIOUS = 20;
|
|
16
34
|
import { applyFeedScores, loadLocalScores, refreshFeedScores } from "./benchmark-feeds.ts";
|
|
17
35
|
|
|
18
36
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -163,6 +181,7 @@ export function normalizeCatalogModel(raw: unknown): CatalogModel | null {
|
|
|
163
181
|
|
|
164
182
|
const model: CatalogModel = {
|
|
165
183
|
slug: id,
|
|
184
|
+
provider: "openrouter",
|
|
166
185
|
canonicalSlug: typeof canonical === "string" && canonical.length > 0 ? canonical : id,
|
|
167
186
|
name: typeof name === "string" && name.length > 0 ? name : id,
|
|
168
187
|
contextLength,
|
|
@@ -259,6 +278,7 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
|
|
|
259
278
|
let bySlug = new Map<string, CatalogModel>();
|
|
260
279
|
let hydrated = false;
|
|
261
280
|
let inflight: Promise<CatalogSnapshot> | null = null;
|
|
281
|
+
let lastShrink: CatalogShrink | null = null;
|
|
262
282
|
const readCache = db.query("SELECT payload, fetched_at_ms, etag, key_scoped FROM catalog_cache WHERE id = 1");
|
|
263
283
|
const writeCache = db.query(
|
|
264
284
|
`INSERT INTO catalog_cache (id, payload, fetched_at_ms, etag, key_scoped) VALUES (1, ?, ?, NULL, ?)
|
|
@@ -407,6 +427,21 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
|
|
|
407
427
|
return snapshot;
|
|
408
428
|
}
|
|
409
429
|
const fetchedAtMs = Date.now();
|
|
430
|
+
// A sharp shrink is adopted — the key's guardrails are authoritative for
|
|
431
|
+
// what may be dispatched — but never silently. Measured once: the
|
|
432
|
+
// admitted set fell 352 → 8 for a day, the cheap default vanished, and a
|
|
433
|
+
// 10x model served every turn (~$32) with nothing in the log to say why.
|
|
434
|
+
if (snapshot !== null && snapshot.models.length >= SHRINK_MIN_PREVIOUS && models.length < snapshot.models.length * SHRINK_KEEP_RATIO) {
|
|
435
|
+
lastShrink = { fromModels: snapshot.models.length, toModels: models.length, atMs: fetchedAtMs };
|
|
436
|
+
log.warn("catalog shrank sharply; routing narrows to what the key now admits", {
|
|
437
|
+
from: snapshot.models.length,
|
|
438
|
+
to: models.length,
|
|
439
|
+
keyScoped,
|
|
440
|
+
});
|
|
441
|
+
} else if (lastShrink !== null && models.length >= lastShrink.fromModels) {
|
|
442
|
+
log.info("catalog recovered its pre-shrink size", { models: models.length, shrunkTo: lastShrink.toModels });
|
|
443
|
+
lastShrink = null;
|
|
444
|
+
}
|
|
410
445
|
// Persist the RAW payload: normalization improvements apply on the next
|
|
411
446
|
// boot without a network fetch. The client returns no headers, so no etag.
|
|
412
447
|
// Only persist the public fallback when keyless: a key-scoped run that fell
|
|
@@ -454,5 +489,8 @@ export function createCatalog(cfg: RouterConfig, upstream: UpstreamClient, db: D
|
|
|
454
489
|
hydrate();
|
|
455
490
|
return bySlug.get(slug);
|
|
456
491
|
},
|
|
492
|
+
lastShrink(): CatalogShrink | null {
|
|
493
|
+
return lastShrink;
|
|
494
|
+
},
|
|
457
495
|
};
|
|
458
496
|
}
|
package/src/catalog/types.ts
CHANGED
|
@@ -45,9 +45,14 @@ export interface QualityScores {
|
|
|
45
45
|
agentic?: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Which upstream serves a catalog model. Slugs are namespaced per provider (`ollama/…`). */
|
|
49
|
+
export type CatalogProvider = "openrouter" | "ollama";
|
|
50
|
+
|
|
48
51
|
export interface CatalogModel {
|
|
49
|
-
/** OpenRouter slug, e.g. `anthropic/claude-sonnet-4.5
|
|
52
|
+
/** OpenRouter slug, e.g. `anthropic/claude-sonnet-4.5`, or `ollama/<id>`. Routing identity. */
|
|
50
53
|
slug: string;
|
|
54
|
+
/** Upstream that dispatches this slug. */
|
|
55
|
+
provider: CatalogProvider;
|
|
51
56
|
/** Immutable dated slug, e.g. `anthropic/claude-4.5-sonnet-20250929`. */
|
|
52
57
|
canonicalSlug: string;
|
|
53
58
|
name: string;
|
|
@@ -83,6 +88,12 @@ export interface CatalogSnapshot {
|
|
|
83
88
|
models: CatalogModel[];
|
|
84
89
|
/** When this snapshot was fetched. */
|
|
85
90
|
fetchedAtMs: number;
|
|
91
|
+
/**
|
|
92
|
+
* Per-provider cost multipliers in force for this snapshot (1 = list price).
|
|
93
|
+
* Set by the composite catalog from live plan usage; absent ⇒ the
|
|
94
|
+
* configured static bias applies.
|
|
95
|
+
*/
|
|
96
|
+
providerBias?: Partial<Record<CatalogProvider, number>>;
|
|
86
97
|
/**
|
|
87
98
|
* True when the snapshot was fetched via key-scoped `GET /models/user`.
|
|
88
99
|
* False when fetched from the public `GET /models` endpoint.
|
|
@@ -92,6 +103,19 @@ export interface CatalogSnapshot {
|
|
|
92
103
|
etag?: string;
|
|
93
104
|
}
|
|
94
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A refresh that dropped most of the catalog. Recorded, not resisted: the
|
|
108
|
+
* router routes over whatever the key admits, but a collapse from hundreds of
|
|
109
|
+
* models to a handful reroutes every turn onto whatever survived — measured
|
|
110
|
+
* once at ~$32 in a day when the cheap default vanished and a 10x model served
|
|
111
|
+
* everything — so it must be visible on `/health` and in the log.
|
|
112
|
+
*/
|
|
113
|
+
export interface CatalogShrink {
|
|
114
|
+
fromModels: number;
|
|
115
|
+
toModels: number;
|
|
116
|
+
atMs: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
95
119
|
export interface CatalogSource {
|
|
96
120
|
/** Returns a snapshot, refreshing from upstream when the cached one is older than the TTL. */
|
|
97
121
|
get(): Promise<CatalogSnapshot>;
|
|
@@ -101,4 +125,10 @@ export interface CatalogSource {
|
|
|
101
125
|
peek(): CatalogSnapshot | null;
|
|
102
126
|
/** Slug lookup against the current snapshot. */
|
|
103
127
|
find(slug: string): CatalogModel | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* The most recent sharp shrink this process observed, or null. Cleared once
|
|
130
|
+
* a later refresh restores at least the pre-shrink size. Optional so fakes
|
|
131
|
+
* and older sources need not implement it.
|
|
132
|
+
*/
|
|
133
|
+
lastShrink?(): CatalogShrink | null;
|
|
104
134
|
}
|