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.
@@ -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.12.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.12.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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
- if (merged !== null && base === lastBase && models === lastOllama && available === lastAvailable && serveBase === lastServeBase && providerBias === lastBias) return merged;
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
+ }
@@ -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 = "openrouter" | "ollama";
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. */
@@ -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
- return assignInPlace(live as unknown as Rec, patch as Rec);
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`). */
@@ -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).
@@ -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
 
@@ -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
  }
@@ -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(),
@@ -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
+ }
@@ -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
- const PROVIDER = "CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ELSE 'openrouter' END";
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 ${PROVIDER} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
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.startsWith("ollama/") ? "ollama" : "openrouter",
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";
@@ -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 { upstream, catalog, ollama, ollamaServing, ollamaUsage, ollamaCostScale } = createProviders(cfg, db, log);
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.
@@ -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
  }