auto-model-router 0.12.0 → 0.13.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 +50 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +16 -1
- package/src/catalog/static-catalog.ts +90 -0
- package/src/catalog/types.ts +2 -2
- package/src/config/apply.ts +5 -1
- package/src/config/defaults.ts +3 -0
- package/src/config/hot-reload.ts +3 -0
- package/src/config/load.ts +3 -0
- package/src/config/schema.ts +38 -0
- package/src/config/types.ts +55 -0
- package/src/config/upstreams.ts +31 -0
- package/src/cost/report.ts +25 -2
- package/src/cost/views.ts +2 -1
- package/src/lib.ts +3 -1
- package/src/server/http.ts +12 -2
- package/src/server/providers.ts +34 -1
- package/src/upstream/anthropic.ts +470 -0
- package/src/upstream/compat.ts +267 -0
- package/src/upstream/multi.ts +23 -9
- package/test/config-wizard.test.ts +1 -1
- package/test/failover.test.ts +1 -0
- package/test/turn.test.ts +1 -0
- package/test/upstreams.test.ts +378 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.13.1",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.13.1",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1365,6 +1365,56 @@ skills: Claude Code's `~/.claude/skills/<name>/` and omp's `~/.omp/agent/skills/
|
|
|
1365
1365
|
the bundle no longer carries, and a skill of the same name the member wrote themselves is
|
|
1366
1366
|
left alone with a note. A remote without skills answers 404 and nothing happens.
|
|
1367
1367
|
|
|
1368
|
+
## Direct upstreams: OpenAI, Azure OpenAI, Anthropic, vLLM
|
|
1369
|
+
|
|
1370
|
+
OpenRouter and Ollama Cloud are the built-in upstreams. `upstreams:` adds named ones the
|
|
1371
|
+
router dispatches to directly, each with a static, priced model list (a direct provider
|
|
1372
|
+
publishes no routing catalog): OpenAI-compatible servers (`kind: openai` — OpenAI itself,
|
|
1373
|
+
vLLM, a gateway), Azure OpenAI (`kind: azure`, the deployment name is the model and the
|
|
1374
|
+
`api-version` is a field), and Anthropic natively (`kind: anthropic`, translated to and from
|
|
1375
|
+
the Messages API, `cache_control` markers kept because Anthropic honours them).
|
|
1376
|
+
|
|
1377
|
+
```yaml
|
|
1378
|
+
upstreams:
|
|
1379
|
+
- id: openai-direct # the catalog namespace: openai-direct/gpt-4o
|
|
1380
|
+
kind: openai
|
|
1381
|
+
baseUrl: https://api.openai.com/v1
|
|
1382
|
+
apiKey: sk-…
|
|
1383
|
+
models:
|
|
1384
|
+
- { id: gpt-4o, input: 2.5, output: 10, cachedInput: 1.25 } # USD per million tokens
|
|
1385
|
+
- { id: gpt-4o-mini, input: 0.15, output: 0.6, cachedInput: 0.075 }
|
|
1386
|
+
- id: azure-eu
|
|
1387
|
+
kind: azure
|
|
1388
|
+
baseUrl: https://my-resource.openai.azure.com
|
|
1389
|
+
apiVersion: 2024-10-21
|
|
1390
|
+
apiKey: …
|
|
1391
|
+
models: [{ id: gpt-4o-deploy, twin: openai/gpt-4o, input: 2.5, output: 10 }]
|
|
1392
|
+
- id: anthropic-direct
|
|
1393
|
+
kind: anthropic
|
|
1394
|
+
baseUrl: https://api.anthropic.com
|
|
1395
|
+
apiKey: sk-ant-…
|
|
1396
|
+
models:
|
|
1397
|
+
- { id: claude-sonnet-4-20250514, twin: anthropic/claude-sonnet-4, input: 3, output: 15, cachedInput: 0.3, cacheWrite: 3.75, supportsReasoning: true, maxCompletionTokens: 64000 }
|
|
1398
|
+
- id: vllm
|
|
1399
|
+
kind: openai
|
|
1400
|
+
baseUrl: http://vllm.internal:8000/v1
|
|
1401
|
+
models: [{ id: llama-3.3-70b, input: 0, output: 0, contextLength: 128000, quality: { coding: 60, intelligence: 55 } }]
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
Each model enters the catalog as `<id>/<model>` and ranks beside everything else: prices from
|
|
1405
|
+
the entry, context length, capabilities and quality scores from the OpenRouter **twin** of the
|
|
1406
|
+
same model (`twin` names it, or the normalised name finds it — `gpt-4o` matches
|
|
1407
|
+
`openai/gpt-4o`), or from the entry when it says so. A model with no scores serves only the
|
|
1408
|
+
trivial tier, as an unbenchmarked OpenRouter model would. `id` must not be an OpenRouter vendor
|
|
1409
|
+
namespace (`openai`, `anthropic`, …), or `openai/gpt-4o` would be ambiguous. A 429 opens a
|
|
1410
|
+
short breaker and an out-of-quota answer a longer one, and the catalog hides that upstream's
|
|
1411
|
+
models while it is open, exactly as for Ollama Cloud. The list hot-reloads: a changed key or a
|
|
1412
|
+
new entry applies to the next turn. `/health` lists each upstream's state, never its key;
|
|
1413
|
+
`report` and `export` name the upstream as the provider of its slugs.
|
|
1414
|
+
|
|
1415
|
+
Not here: Bedrock and Vertex need cloud signing and are a later addition; a gateway that
|
|
1416
|
+
speaks OpenAI in front of them works today as `kind: openai`.
|
|
1417
|
+
|
|
1368
1418
|
## Multiple coding harnesses, one router
|
|
1369
1419
|
|
|
1370
1420
|
**One router process for everything.** omp's embed extension binds a private
|
package/package.json
CHANGED
package/src/catalog/composite.ts
CHANGED
|
@@ -34,8 +34,13 @@ export interface CompositeBias {
|
|
|
34
34
|
live?: () => { costBias: number; biasUntilUsage: number };
|
|
35
35
|
/** False when OpenRouter cannot dispatch (no key): its models are listed for metadata only, never served. Default true. */
|
|
36
36
|
serveOpenRouter?: () => boolean;
|
|
37
|
+
/** Named upstreams' models, built from the OpenRouter models (twins) and filtered by each upstream's breaker. */
|
|
38
|
+
named?: { models(openrouter: readonly CatalogModel[]): readonly CatalogModel[]; serving(id: string): boolean };
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/** Shared empty list, so a deployment without named upstreams keeps the merged snapshot's identity. */
|
|
42
|
+
const NO_NAMED: readonly CatalogModel[] = [];
|
|
43
|
+
|
|
39
44
|
export function createCompositeCatalog(
|
|
40
45
|
openrouter: CatalogSource,
|
|
41
46
|
ollama: OllamaCatalogSource,
|
|
@@ -47,6 +52,8 @@ export function createCompositeCatalog(
|
|
|
47
52
|
let lastAvailable = true;
|
|
48
53
|
let lastServeBase = true;
|
|
49
54
|
let lastBias = 1;
|
|
55
|
+
let lastNamed: readonly CatalogModel[] = [];
|
|
56
|
+
let lastNamedServing = "";
|
|
50
57
|
let merged: CatalogSnapshot | null = null;
|
|
51
58
|
|
|
52
59
|
/** The multiplier in force from the latest usage reading (no network). */
|
|
@@ -59,13 +66,20 @@ export function createCompositeCatalog(
|
|
|
59
66
|
const available = availability.available();
|
|
60
67
|
const serveBase = bias.serveOpenRouter?.() ?? true;
|
|
61
68
|
const providerBias = currentBias();
|
|
62
|
-
|
|
69
|
+
// Named upstreams: every enabled entry's models, minus those of an upstream in cooldown.
|
|
70
|
+
const namedAll = bias.named?.models(base.models) ?? NO_NAMED;
|
|
71
|
+
const namedServing = namedAll.map((m) => (bias.named?.serving(m.provider) ?? true ? "1" : "0")).join("");
|
|
72
|
+
if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && serveBase === lastServeBase && providerBias === lastBias && namedAll === lastNamed && namedServing === lastNamedServing) return merged;
|
|
63
73
|
lastBase = base;
|
|
64
74
|
lastOllama = models;
|
|
65
75
|
lastAvailable = available;
|
|
66
76
|
lastServeBase = serveBase;
|
|
67
77
|
lastBias = providerBias;
|
|
78
|
+
lastNamed = namedAll;
|
|
79
|
+
lastNamedServing = namedServing;
|
|
80
|
+
const named = namedAll.filter((m) => bias.named?.serving(m.provider) ?? true);
|
|
68
81
|
merged = serveBase ? mergeSnapshots(base, available ? models : []) : { ...base, models: available ? [...models] : [] };
|
|
82
|
+
if (named.length > 0) merged = { ...merged, models: [...merged.models, ...named] };
|
|
69
83
|
// A fresh object either way once anything changed; stamp the live bias so
|
|
70
84
|
// candidate scoring reads it off the snapshot it is ranking.
|
|
71
85
|
merged = { ...merged, providerBias: { ollama: providerBias } };
|
|
@@ -77,6 +91,7 @@ export function createCompositeCatalog(
|
|
|
77
91
|
if (fromBase !== undefined) return fromBase;
|
|
78
92
|
for (const m of lastOllama) if (m.slug === slug) return m;
|
|
79
93
|
for (const m of ollama.peek()) if (m.slug === slug) return m;
|
|
94
|
+
for (const m of lastNamed) if (m.slug === slug) return m;
|
|
80
95
|
return undefined;
|
|
81
96
|
}
|
|
82
97
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog models for the named upstreams in `upstreams: []`.
|
|
3
|
+
*
|
|
4
|
+
* A direct provider publishes no routing catalog the way OpenRouter does, so
|
|
5
|
+
* each entry names its models with prices (USD per million tokens) and what
|
|
6
|
+
* routing needs to know. Quality scores come from the entry when given,
|
|
7
|
+
* otherwise from the OpenRouter twin of the same model (`twin` names it, or
|
|
8
|
+
* the normalised name finds it), so a direct `gpt-4o` ranks like OpenRouter's
|
|
9
|
+
* `openai/gpt-4o` rather than as an unscored model stuck in the trivial tier.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RouterConfig, UpstreamEntry, UpstreamModelConfig } from "../config/types.ts";
|
|
13
|
+
import type { Logger } from "../util/log.ts";
|
|
14
|
+
import { normalizeModelKey } from "./benchmark-feeds.ts";
|
|
15
|
+
import { twinIndex } from "./ollama-catalog.ts";
|
|
16
|
+
import type { CatalogModel, Modality } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
function tokenizerFor(kind: UpstreamEntry["kind"], twin: CatalogModel | null): string {
|
|
19
|
+
if (twin !== null) return twin.tokenizer;
|
|
20
|
+
return kind === "anthropic" ? "Claude" : kind === "openai" || kind === "azure" ? "GPT" : "Other";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One entry's models as catalog models. Pure. */
|
|
24
|
+
export function buildUpstreamModels(entry: UpstreamEntry, openrouter: readonly CatalogModel[]): CatalogModel[] {
|
|
25
|
+
const twins = twinIndex(openrouter);
|
|
26
|
+
const bySlug = new Map(openrouter.map((m) => [m.slug, m] as const));
|
|
27
|
+
const out: CatalogModel[] = [];
|
|
28
|
+
for (const m of entry.models) {
|
|
29
|
+
const twin = (m.twin !== undefined ? bySlug.get(m.twin) : undefined) ?? twins.get(normalizeModelKey(m.id)) ?? null;
|
|
30
|
+
const modalities: Modality[] = ["text"];
|
|
31
|
+
if (m.vision ?? twin?.inputModalities.includes("image") ?? false) modalities.push("image");
|
|
32
|
+
const price: CatalogModel["price"] = { prompt: m.input / 1e6, completion: m.output / 1e6 };
|
|
33
|
+
if (m.cachedInput !== undefined) price.cacheRead = m.cachedInput / 1e6;
|
|
34
|
+
if (m.cacheWrite !== undefined) price.cacheWrite = m.cacheWrite / 1e6;
|
|
35
|
+
const model: CatalogModel = {
|
|
36
|
+
slug: `${entry.id}/${m.id}`,
|
|
37
|
+
canonicalSlug: `${entry.id}/${m.id}`,
|
|
38
|
+
name: `${m.name ?? m.id} (${entry.id})`,
|
|
39
|
+
provider: entry.id,
|
|
40
|
+
contextLength: m.contextLength ?? twin?.contextLength ?? 128_000,
|
|
41
|
+
supportsTools: m.supportsTools ?? twin?.supportsTools ?? true,
|
|
42
|
+
supportsReasoning: m.supportsReasoning ?? twin?.supportsReasoning ?? false,
|
|
43
|
+
reasoningMandatory: twin?.reasoningMandatory ?? false,
|
|
44
|
+
supportsToolChoice: m.supportsToolChoice ?? true,
|
|
45
|
+
inputModalities: modalities,
|
|
46
|
+
price,
|
|
47
|
+
priceTiers: [],
|
|
48
|
+
quality: m.quality !== undefined ? { ...m.quality } : twin === null ? {} : { ...twin.quality },
|
|
49
|
+
tokenizer: tokenizerFor(entry.kind, twin),
|
|
50
|
+
// A $0 model here is a self-hosted server, not a public provider's rate-limited free tier: never excluded as "free".
|
|
51
|
+
isFree: false,
|
|
52
|
+
createdAtMs: twin?.createdAtMs ?? 0,
|
|
53
|
+
author: entry.id,
|
|
54
|
+
};
|
|
55
|
+
const maxOut = m.maxCompletionTokens ?? twin?.maxCompletionTokens;
|
|
56
|
+
if (maxOut !== undefined) model.maxCompletionTokens = maxOut;
|
|
57
|
+
out.push(model);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface StaticCatalogSource {
|
|
63
|
+
/** Models of every enabled entry, rebuilt when the entries or the OpenRouter models change. */
|
|
64
|
+
get(openrouter: readonly CatalogModel[]): CatalogModel[];
|
|
65
|
+
/** The last built set. */
|
|
66
|
+
peek(): CatalogModel[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Memoised over the live `cfg.upstreams` array (replaced wholesale on a change) and the OpenRouter models. */
|
|
70
|
+
export function createStaticCatalogSource(cfg: RouterConfig, log?: Logger): StaticCatalogSource {
|
|
71
|
+
let lastEntries: readonly UpstreamEntry[] | null = null;
|
|
72
|
+
let lastBase: readonly CatalogModel[] | null = null;
|
|
73
|
+
let built: CatalogModel[] = [];
|
|
74
|
+
return {
|
|
75
|
+
get(openrouter) {
|
|
76
|
+
if (cfg.upstreams === lastEntries && openrouter === lastBase) return built;
|
|
77
|
+
lastEntries = cfg.upstreams;
|
|
78
|
+
lastBase = openrouter;
|
|
79
|
+
built = cfg.upstreams.filter((u) => u.enabled).flatMap((u) => buildUpstreamModels(u, openrouter));
|
|
80
|
+
if (built.length > 0) log?.debug("named upstream models built", { models: built.length, upstreams: cfg.upstreams.filter((u) => u.enabled).map((u) => u.id).join(", ") });
|
|
81
|
+
return built;
|
|
82
|
+
},
|
|
83
|
+
peek: () => built,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The published model that an entry names, for clients that need its limits. */
|
|
88
|
+
export function upstreamModel(entry: UpstreamEntry, modelId: string): UpstreamModelConfig | undefined {
|
|
89
|
+
return entry.models.find((m) => m.id === modelId);
|
|
90
|
+
}
|
package/src/catalog/types.ts
CHANGED
|
@@ -45,8 +45,8 @@ 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 =
|
|
48
|
+
/** Which upstream serves a catalog model: `openrouter`, `ollama`, or a named upstream's id. Slugs are namespaced per provider (`ollama/…`, `<id>/…`). */
|
|
49
|
+
export type CatalogProvider = string;
|
|
50
50
|
|
|
51
51
|
export interface CatalogModel {
|
|
52
52
|
/** OpenRouter slug, e.g. `anthropic/claude-sonnet-4.5`, or `ollama/<id>`. Routing identity. */
|
package/src/config/apply.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { RouterConfig } from "./types.ts";
|
|
17
|
+
import { completeUpstreams } from "./upstreams.ts";
|
|
17
18
|
import type { DeepPartial } from "./load.ts";
|
|
18
19
|
|
|
19
20
|
type Rec = Record<string, unknown>;
|
|
@@ -57,7 +58,10 @@ export function assignInPlace(target: Rec, source: Rec, prefix = "", opts: { pru
|
|
|
57
58
|
|
|
58
59
|
/** `assignInPlace` over a typed config. Returns the dotted paths that changed. */
|
|
59
60
|
export function applyConfigPatch(live: RouterConfig, patch: DeepPartial<RouterConfig>): string[] {
|
|
60
|
-
|
|
61
|
+
const changed = assignInPlace(live as unknown as Rec, patch as Rec);
|
|
62
|
+
// A patched upstream list arrives sparse (what the author set); clients read complete records.
|
|
63
|
+
if (changed.some((c) => c === "upstreams" || c.startsWith("upstreams."))) completeUpstreams(live);
|
|
64
|
+
return changed;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/** True when any changed path falls inside `block` (`"ollama"` matches `ollama.apiKey`). */
|
package/src/config/defaults.ts
CHANGED
|
@@ -62,6 +62,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
62
62
|
// its dashboard is that share × the plan's credits. Unknown until set.
|
|
63
63
|
planCreditsUsd: 0,
|
|
64
64
|
},
|
|
65
|
+
// Named direct upstreams (OpenAI, Azure OpenAI, Anthropic, vLLM…): none until configured.
|
|
66
|
+
// Each entry's own defaults are filled in by loadConfig (see load.ts).
|
|
67
|
+
upstreams: [],
|
|
65
68
|
benchmarks: {
|
|
66
69
|
// Keyless BenchLM alone fills real gaps, so this is on by default; the AA
|
|
67
70
|
// feed only actually fires once a key is present (config or env).
|
package/src/config/hot-reload.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs";
|
|
|
30
30
|
import { parse as parseYaml } from "yaml";
|
|
31
31
|
import { configInputSchema } from "./schema.ts";
|
|
32
32
|
import { assignInPlace } from "./apply.ts";
|
|
33
|
+
import { completeUpstreams } from "./upstreams.ts";
|
|
33
34
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
34
35
|
import { deepMerge, resolveTilde } from "./load.ts";
|
|
35
36
|
import type { RouterConfig } from "./types.ts";
|
|
@@ -182,6 +183,8 @@ export function watchConfig(
|
|
|
182
183
|
// One in-place pass over the whole config: block identity survives, and a
|
|
183
184
|
// knob deleted from the file reverts, exactly as a restart would leave it.
|
|
184
185
|
const changed = assignInPlace(live as unknown as Record<string, unknown>, staged, "", { prune: true });
|
|
186
|
+
// A reloaded upstream list is as sparse as the file; clients read complete records.
|
|
187
|
+
if (changed.some((c) => c === "upstreams" || c.startsWith("upstreams."))) completeUpstreams(live);
|
|
185
188
|
if (changed.length > 0) opts.onReload?.({ changed });
|
|
186
189
|
};
|
|
187
190
|
|
package/src/config/load.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
|
6
6
|
import { configInputSchema } from "./schema.ts";
|
|
7
7
|
import { resolveOllamaKey, resolveOpenRouterKey, type ResolvedCredential } from "./omp-credentials.ts";
|
|
8
8
|
import type { RouterConfig } from "./types.ts";
|
|
9
|
+
import { completeUpstreams } from "./upstreams.ts";
|
|
9
10
|
|
|
10
11
|
const LOG_LEVELS: readonly RouterConfig["logLevel"][] = ["silent", "error", "warn", "info", "debug"];
|
|
11
12
|
|
|
@@ -161,6 +162,8 @@ export function loadConfig(opts?: { path?: string; overrides?: DeepPartial<Route
|
|
|
161
162
|
const ollamaCredential = resolveOllamaKey(cfg.ollama.apiKey);
|
|
162
163
|
cfg.ollama.apiKey = ollamaCredential.apiKey;
|
|
163
164
|
ollamaKeyProvenance.set(cfg, ollamaCredential);
|
|
165
|
+
// Named upstream entries: fill the optional fields so every client reads a complete record.
|
|
166
|
+
completeUpstreams(cfg);
|
|
164
167
|
|
|
165
168
|
return cfg;
|
|
166
169
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -57,6 +57,43 @@ const ollama = z.strictObject({
|
|
|
57
57
|
planCreditsUsd: z.number().nonnegative().optional(),
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
/** Ids that would collide with OpenRouter's own vendor namespaces or the built-in upstreams. */
|
|
61
|
+
export const RESERVED_UPSTREAM_IDS: readonly string[] = ["openrouter", "ollama", "openai", "anthropic", "google", "meta-llama", "mistralai", "x-ai", "deepseek", "qwen", "amazon", "microsoft", "cohere", "perplexity", "nvidia", "moonshotai", "z-ai", "minimax"];
|
|
62
|
+
|
|
63
|
+
const upstreamModel = z.strictObject({
|
|
64
|
+
id: z.string().min(1),
|
|
65
|
+
name: z.string().optional(),
|
|
66
|
+
input: z.number().nonnegative(),
|
|
67
|
+
output: z.number().nonnegative(),
|
|
68
|
+
cachedInput: z.number().nonnegative().optional(),
|
|
69
|
+
cacheWrite: z.number().nonnegative().optional(),
|
|
70
|
+
contextLength: z.number().int().positive().optional(),
|
|
71
|
+
maxCompletionTokens: z.number().int().positive().optional(),
|
|
72
|
+
supportsTools: z.boolean().optional(),
|
|
73
|
+
supportsReasoning: z.boolean().optional(),
|
|
74
|
+
supportsToolChoice: z.boolean().optional(),
|
|
75
|
+
vision: z.boolean().optional(),
|
|
76
|
+
quality: z.strictObject({ intelligence: z.number().optional(), coding: z.number().optional(), agentic: z.number().optional() }).optional(),
|
|
77
|
+
twin: z.string().optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const upstream = z.strictObject({
|
|
81
|
+
id: z
|
|
82
|
+
.string()
|
|
83
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,31}$/, "lowercase letters, digits and dashes")
|
|
84
|
+
.refine((id) => !RESERVED_UPSTREAM_IDS.includes(id), { message: "this id is a vendor namespace on OpenRouter or a built-in upstream; use e.g. openai-direct, azure-eu, anthropic-direct, vllm" }),
|
|
85
|
+
kind: z.enum(["openai", "azure", "anthropic"]),
|
|
86
|
+
enabled: z.boolean().optional(),
|
|
87
|
+
baseUrl: z.string().min(1),
|
|
88
|
+
apiKey: z.string().optional(),
|
|
89
|
+
apiVersion: z.string().optional(),
|
|
90
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
91
|
+
timeoutMs: z.number().positive().optional(),
|
|
92
|
+
rateLimitCooldownMs: z.number().nonnegative().optional(),
|
|
93
|
+
quotaCooldownMs: z.number().nonnegative().optional(),
|
|
94
|
+
models: z.array(upstreamModel),
|
|
95
|
+
});
|
|
96
|
+
|
|
60
97
|
const benchmarks = z.strictObject({
|
|
61
98
|
enabled: z.boolean().optional(),
|
|
62
99
|
artificialAnalysisApiKey: z.string().optional(),
|
|
@@ -268,6 +305,7 @@ export const configInputSchema = z.strictObject({
|
|
|
268
305
|
})
|
|
269
306
|
.optional(),
|
|
270
307
|
ollama: ollama.optional(),
|
|
308
|
+
upstreams: z.array(upstream).optional(),
|
|
271
309
|
filters: filters.optional(),
|
|
272
310
|
classifier: classifier.optional(),
|
|
273
311
|
escalation: escalation.optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -843,10 +843,65 @@ export interface CompactionConfig {
|
|
|
843
843
|
collapseDuplicateResults: boolean;
|
|
844
844
|
}
|
|
845
845
|
|
|
846
|
+
/** Which API a named upstream speaks. */
|
|
847
|
+
export type UpstreamKind = "openai" | "azure" | "anthropic";
|
|
848
|
+
|
|
849
|
+
/** One model a named upstream serves, with what routing needs since there is no catalog to fetch. */
|
|
850
|
+
export interface UpstreamModelConfig {
|
|
851
|
+
/** The model id the provider knows (an Azure deployment name for `azure`). The catalog slug is `<upstream id>/<id>`. */
|
|
852
|
+
id: string;
|
|
853
|
+
name?: string;
|
|
854
|
+
/** USD per million prompt tokens. */
|
|
855
|
+
input: number;
|
|
856
|
+
/** USD per million completion tokens. */
|
|
857
|
+
output: number;
|
|
858
|
+
/** USD per million cached prompt tokens, when the provider discounts them. */
|
|
859
|
+
cachedInput?: number;
|
|
860
|
+
/** USD per million prompt tokens written to cache (Anthropic). */
|
|
861
|
+
cacheWrite?: number;
|
|
862
|
+
/** Absent ⇒ the OpenRouter twin's, else 128k. */
|
|
863
|
+
contextLength?: number;
|
|
864
|
+
maxCompletionTokens?: number;
|
|
865
|
+
supportsTools?: boolean;
|
|
866
|
+
supportsReasoning?: boolean;
|
|
867
|
+
supportsToolChoice?: boolean;
|
|
868
|
+
/** Accepts images. Absent ⇒ the twin's. */
|
|
869
|
+
vision?: boolean;
|
|
870
|
+
/** 0-100 scores; absent ⇒ borrowed from the OpenRouter twin. */
|
|
871
|
+
quality?: { intelligence?: number; coding?: number; agentic?: number };
|
|
872
|
+
/** The OpenRouter slug whose scores and capabilities this model borrows; absent ⇒ matched by name. */
|
|
873
|
+
twin?: string;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* A named upstream beside OpenRouter and Ollama Cloud: OpenAI, Azure OpenAI,
|
|
878
|
+
* Anthropic, or any OpenAI-compatible server (vLLM, a gateway). Its models
|
|
879
|
+
* enter the catalog as `<id>/<model>` and dispatch to it.
|
|
880
|
+
*/
|
|
881
|
+
export interface UpstreamEntry {
|
|
882
|
+
/** Lowercase slug; the catalog namespace. Must not be an OpenRouter vendor namespace such as `openai` or `anthropic`. */
|
|
883
|
+
id: string;
|
|
884
|
+
kind: UpstreamKind;
|
|
885
|
+
enabled: boolean;
|
|
886
|
+
/** `https://api.openai.com/v1`, `https://<resource>.openai.azure.com`, `https://api.anthropic.com`, `http://vllm:8000/v1`. */
|
|
887
|
+
baseUrl: string;
|
|
888
|
+
apiKey: string;
|
|
889
|
+
/** Azure only: the `api-version` query parameter. */
|
|
890
|
+
apiVersion: string;
|
|
891
|
+
/** Extra request headers, e.g. a gateway's own auth. */
|
|
892
|
+
headers: Record<string, string>;
|
|
893
|
+
timeoutMs: number;
|
|
894
|
+
rateLimitCooldownMs: number;
|
|
895
|
+
quotaCooldownMs: number;
|
|
896
|
+
models: UpstreamModelConfig[];
|
|
897
|
+
}
|
|
898
|
+
|
|
846
899
|
export interface RouterConfig {
|
|
847
900
|
server: ServerConfig;
|
|
848
901
|
openrouter: OpenRouterConfig;
|
|
849
902
|
ollama: OllamaConfig;
|
|
903
|
+
/** Named direct upstreams; empty by default. */
|
|
904
|
+
upstreams: UpstreamEntry[];
|
|
850
905
|
benchmarks: BenchmarksConfig;
|
|
851
906
|
tiers: Record<Tier, TierConfig>;
|
|
852
907
|
tasks: Record<TaskType, TaskConfig>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named upstream entries as they arrive (a file, an env-built patch, a dashboard patch)
|
|
3
|
+
* carry only what the author set; every client reads a complete record, so the optional
|
|
4
|
+
* fields are filled here, at load and after every live apply.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { UpstreamEntry } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Fills an upstream entry's optional fields, so every client reads a complete record. */
|
|
10
|
+
export function completeUpstreamEntry(raw: Record<string, unknown>): UpstreamEntry {
|
|
11
|
+
const kind = raw.kind as UpstreamEntry["kind"];
|
|
12
|
+
return {
|
|
13
|
+
id: raw.id as string,
|
|
14
|
+
kind,
|
|
15
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
|
16
|
+
baseUrl: raw.baseUrl as string,
|
|
17
|
+
apiKey: typeof raw.apiKey === "string" ? raw.apiKey : "",
|
|
18
|
+
apiVersion: typeof raw.apiVersion === "string" ? raw.apiVersion : "2024-10-21",
|
|
19
|
+
headers: (raw.headers as Record<string, string> | undefined) ?? {},
|
|
20
|
+
timeoutMs: typeof raw.timeoutMs === "number" ? raw.timeoutMs : 600_000,
|
|
21
|
+
rateLimitCooldownMs: typeof raw.rateLimitCooldownMs === "number" ? raw.rateLimitCooldownMs : 60_000,
|
|
22
|
+
quotaCooldownMs: typeof raw.quotaCooldownMs === "number" ? raw.quotaCooldownMs : 15 * 60_000,
|
|
23
|
+
models: (raw.models as UpstreamEntry["models"] | undefined) ?? [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Replaces the live list's entries with completed ones, in place, when a patch touched them. */
|
|
28
|
+
export function completeUpstreams(cfg: { upstreams: UpstreamEntry[] }): void {
|
|
29
|
+
const completed = cfg.upstreams.map((u) => completeUpstreamEntry(u as unknown as Record<string, unknown>));
|
|
30
|
+
cfg.upstreams.splice(0, cfg.upstreams.length, ...completed);
|
|
31
|
+
}
|
package/src/cost/report.ts
CHANGED
|
@@ -137,7 +137,30 @@ const USD = "COALESCE(reported_usd, predicted_usd)";
|
|
|
137
137
|
const PT = "json_extract(usage, '$.promptTokens')";
|
|
138
138
|
const CT = "json_extract(usage, '$.cachedTokens')";
|
|
139
139
|
const COMP = "json_extract(usage, '$.completionTokens')";
|
|
140
|
-
|
|
140
|
+
/** Named upstream ids the ledger's provider derivation knows; set by createProviders from the live config. */
|
|
141
|
+
let knownUpstreamIds: () => readonly string[] = () => [];
|
|
142
|
+
/** Ids, or a getter read live so a hot-reloaded list applies; an embedder (the team edition) calls this too, since the registry is per process. */
|
|
143
|
+
export function setKnownUpstreamIds(ids: readonly string[] | (() => readonly string[])): void {
|
|
144
|
+
const read = typeof ids === "function" ? ids : () => ids;
|
|
145
|
+
knownUpstreamIds = () => read().filter((id) => /^[a-z0-9][a-z0-9-]{0,31}$/.test(id));
|
|
146
|
+
}
|
|
147
|
+
export function knownUpstreams(): readonly string[] {
|
|
148
|
+
return knownUpstreamIds();
|
|
149
|
+
}
|
|
150
|
+
/** The provider of a slug: its namespace when that names a known upstream, else OpenRouter's own. */
|
|
151
|
+
export function providerOfSlug(slug: string): string {
|
|
152
|
+
if (slug.startsWith("ollama/")) return "ollama";
|
|
153
|
+
const cut = slug.indexOf("/");
|
|
154
|
+
if (cut > 0) {
|
|
155
|
+
const head = slug.slice(0, cut);
|
|
156
|
+
if (knownUpstreamIds().includes(head)) return head;
|
|
157
|
+
}
|
|
158
|
+
return "openrouter";
|
|
159
|
+
}
|
|
160
|
+
/** SQL twin of providerOfSlug; ids are validated to a slug alphabet so they can be inlined. */
|
|
161
|
+
function providerCase(): string {
|
|
162
|
+
return `CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ${knownUpstreamIds().map((id) => `WHEN slug LIKE '${id}/%' THEN '${id}'`).join(" ")} ELSE 'openrouter' END`;
|
|
163
|
+
}
|
|
141
164
|
const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
|
|
142
165
|
const EST = "json_extract(usage, '$.cachedEstimated') = 1";
|
|
143
166
|
/** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
|
|
@@ -277,7 +300,7 @@ export function buildUsageReport(
|
|
|
277
300
|
|
|
278
301
|
const windowSpend = t.spend;
|
|
279
302
|
const providers = (
|
|
280
|
-
db.query(`SELECT ${
|
|
303
|
+
db.query(`SELECT ${providerCase()} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
|
|
281
304
|
).map((r) => toRow(r, windowSpend));
|
|
282
305
|
|
|
283
306
|
const modelRows = db
|
package/src/cost/views.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* columns and accept a read-only database handle.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { providerOfSlug } from "./report.ts";
|
|
11
12
|
import type { Database } from "bun:sqlite";
|
|
12
13
|
import { harnessFilter } from "./report.ts";
|
|
13
14
|
|
|
@@ -120,7 +121,7 @@ export function exportRows(db: Database, sinceMs: number, harness: HarnessScope)
|
|
|
120
121
|
day: r.day,
|
|
121
122
|
harnessId: r.harness_id,
|
|
122
123
|
slug: r.slug,
|
|
123
|
-
provider: r.slug
|
|
124
|
+
provider: providerOfSlug(r.slug),
|
|
124
125
|
dispatches: r.dispatches,
|
|
125
126
|
promptTokens: r.prompt_tokens,
|
|
126
127
|
cachedTokens: r.cached_tokens,
|
package/src/lib.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
export { startServer, type ReconfigureResult, type StartedServer } from "./server/http.ts";
|
|
16
16
|
export { loadConfig, apiKeySource } from "./config/load.ts";
|
|
17
17
|
export { DEFAULT_CONFIG } from "./config/defaults.ts";
|
|
18
|
-
export type { RouterConfig } from "./config/types.ts";
|
|
18
|
+
export type { RouterConfig, UpstreamEntry, UpstreamKind, UpstreamModelConfig } from "./config/types.ts";
|
|
19
|
+
export { RESERVED_UPSTREAM_IDS } from "./config/schema.ts";
|
|
20
|
+
export { setKnownUpstreamIds, providerOfSlug } from "./cost/report.ts";
|
|
19
21
|
export type { DeepPartial } from "./config/load.ts";
|
|
20
22
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
21
23
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
package/src/server/http.ts
CHANGED
|
@@ -226,7 +226,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
226
226
|
if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
227
227
|
const db = openDb(cfg.ledger.path);
|
|
228
228
|
const ledger = createLedger(db, cfg);
|
|
229
|
-
const
|
|
229
|
+
const providers = createProviders(cfg, db, log);
|
|
230
|
+
const { upstream, catalog, ollama, ollamaServing, ollamaUsage, ollamaCostScale } = providers;
|
|
230
231
|
const conversations = createConversationStore(db);
|
|
231
232
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
232
233
|
const context = createBridgeFromConfig(cfg, db);
|
|
@@ -272,6 +273,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
272
273
|
if (cfg.ollama.enabled) log.warn("no OpenRouter key: routing over Ollama Cloud models only (OpenRouter's catalog is read for metadata, never served)");
|
|
273
274
|
else log.warn("OPENROUTER_API_KEY is not set and Ollama is off; /v1/chat/completions will fail at dispatch time");
|
|
274
275
|
}
|
|
276
|
+
for (const u of cfg.upstreams) {
|
|
277
|
+
if (!u.enabled) continue;
|
|
278
|
+
log.info(`named upstream enabled: ${u.id}`, { kind: u.kind, baseUrl: u.baseUrl, models: u.models.length, apiKeyConfigured: u.apiKey !== "" });
|
|
279
|
+
}
|
|
275
280
|
if (cfg.ollama.enabled) {
|
|
276
281
|
log.info("ollama cloud upstream enabled", {
|
|
277
282
|
baseUrl: cfg.ollama.baseUrl,
|
|
@@ -639,7 +644,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
639
644
|
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
640
645
|
// Which upstreams turns can actually be served from: OpenRouter needs
|
|
641
646
|
// its key; Ollama needs to be on and out of cooldown.
|
|
642
|
-
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollamaServing() ? ["ollama"] : [])],
|
|
647
|
+
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollamaServing() ? ["ollama"] : []), ...providers.namedServing()],
|
|
648
|
+
// Named direct upstreams: never the key. `available` is each one's breaker.
|
|
649
|
+
upstreams: cfg.upstreams.map((u) => {
|
|
650
|
+
const client = providers.named(u.id);
|
|
651
|
+
return { id: u.id, kind: u.kind, enabled: u.enabled, baseUrl: u.baseUrl, apiKeyConfigured: u.apiKey !== "", models: u.models.length, available: client?.available() ?? true, cooldownUntilMs: client?.cooldownUntilMs() ?? null, lastTrip: client?.lastTrip() ?? null };
|
|
652
|
+
}),
|
|
643
653
|
// Provenance only; never the key itself.
|
|
644
654
|
apiKeySource: apiKeySource(cfg).source,
|
|
645
655
|
// Provenance only; never the agentdox token itself.
|
package/src/server/providers.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Database } from "bun:sqlite";
|
|
8
8
|
import { createCompositeCatalog } from "../catalog/composite.ts";
|
|
9
|
+
import { createStaticCatalogSource } from "../catalog/static-catalog.ts";
|
|
10
|
+
import { createAnthropicClient } from "../upstream/anthropic.ts";
|
|
11
|
+
import { createCompatClient, type NamedUpstreamClient } from "../upstream/compat.ts";
|
|
9
12
|
import { createOllamaCatalog } from "../catalog/ollama-catalog.ts";
|
|
10
13
|
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
11
14
|
import type { CatalogSource } from "../catalog/types.ts";
|
|
@@ -13,6 +16,7 @@ import type { RouterConfig } from "../config/types.ts";
|
|
|
13
16
|
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
14
17
|
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
15
18
|
import { createLedger } from "../cost/ledger.ts";
|
|
19
|
+
import { setKnownUpstreamIds } from "../cost/report.ts";
|
|
16
20
|
import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
17
21
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
18
22
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
@@ -29,6 +33,10 @@ export interface Providers {
|
|
|
29
33
|
ollamaUsage: OllamaUsageSource;
|
|
30
34
|
/** Multiplier that brings the ledger's Ollama estimate in line with the plan meter; 1 until calibrated. */
|
|
31
35
|
ollamaCostScale: () => number;
|
|
36
|
+
/** The client for a named upstream id, built on first use; undefined for an id not configured. */
|
|
37
|
+
named(id: string): NamedUpstreamClient | undefined;
|
|
38
|
+
/** Ids of the named upstreams that can take a turn now: enabled, keyed, out of cooldown. */
|
|
39
|
+
namedServing(): string[];
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
|
|
@@ -53,18 +61,43 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
53
61
|
// estimate can be scaled to what ollama.com actually bills.
|
|
54
62
|
calibration: { db, ledgerUsd: () => ledgerForCalibration.providerSpendSince?.("ollama/", 0) ?? 0, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
|
|
55
63
|
});
|
|
64
|
+
// Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
|
|
65
|
+
// first needed and kept — its breaker state must survive config reloads — while
|
|
66
|
+
// the entry it reads is looked up live, so a changed key or URL applies at once.
|
|
67
|
+
const namedClients = new Map<string, NamedUpstreamClient>();
|
|
68
|
+
const named = (id: string): NamedUpstreamClient | undefined => {
|
|
69
|
+
const entry = cfg.upstreams.find((u) => u.id === id);
|
|
70
|
+
if (entry === undefined) return undefined;
|
|
71
|
+
let client = namedClients.get(id);
|
|
72
|
+
if (client === undefined) {
|
|
73
|
+
client = entry.kind === "anthropic" ? createAnthropicClient(cfg, id) : createCompatClient(cfg, id);
|
|
74
|
+
namedClients.set(id, client);
|
|
75
|
+
}
|
|
76
|
+
return client;
|
|
77
|
+
};
|
|
78
|
+
const namedServingOne = (id: string): boolean => {
|
|
79
|
+
const entry = cfg.upstreams.find((u) => u.id === id);
|
|
80
|
+
if (entry === undefined || !entry.enabled || entry.apiKey === "" && entry.kind !== "openai") return entry !== undefined && entry.enabled && (named(id)?.available() ?? false);
|
|
81
|
+
return named(id)?.available() ?? false;
|
|
82
|
+
};
|
|
83
|
+
const namedServing = (): string[] => cfg.upstreams.filter((u) => u.enabled && namedServingOne(u.id)).map((u) => u.id);
|
|
84
|
+
const staticCatalog = createStaticCatalogSource(cfg, log);
|
|
85
|
+
setKnownUpstreamIds(() => cfg.upstreams.map((u) => u.id));
|
|
56
86
|
return {
|
|
57
|
-
upstream: createMultiUpstream(openrouter, ollama),
|
|
87
|
+
upstream: createMultiUpstream(openrouter, ollama, named, () => cfg.upstreams.map((u) => u.id)),
|
|
58
88
|
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), { available: ollamaServing, cooldownUntilMs: () => ollama.cooldownUntilMs(), lastTrip: () => ollama.lastTrip() }, {
|
|
59
89
|
costBias: cfg.ollama.costBias,
|
|
60
90
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
61
91
|
usage: ollamaUsage,
|
|
62
92
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
63
93
|
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
94
|
+
named: { models: (base) => staticCatalog.get(base), serving: namedServingOne },
|
|
64
95
|
}),
|
|
65
96
|
ollama,
|
|
66
97
|
ollamaServing,
|
|
67
98
|
ollamaUsage,
|
|
68
99
|
ollamaCostScale: () => ollamaUsage.calibration()?.factor ?? 1,
|
|
100
|
+
named,
|
|
101
|
+
namedServing,
|
|
69
102
|
};
|
|
70
103
|
}
|