auto-model-router 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.11.0",
10
+ "version": "0.13.0",
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.11.0",
17
+ "version": "0.13.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1355,6 +1355,66 @@ becomes Claude Code's key helper and, with `--profile`, goes on PATH. `connect
1355
1355
  `remote.json` records the executable, and a refresh from inside omp keeps the helper
1356
1356
  pointed at it.
1357
1357
 
1358
+ ### The remote's skills come along
1359
+
1360
+ A remote that serves a skills bundle (`GET <url>/setup/skills` with the member key: a
1361
+ version and a list of `{ name, files }`, each with a `SKILL.md`) has it installed by
1362
+ `connect`, and by every refresh, into the harnesses it configured that read user-level
1363
+ skills: Claude Code's `~/.claude/skills/<name>/` and omp's `~/.omp/agent/skills/<name>/`.
1364
+ `<router home>/skills-installed.json` records what was placed, so an update removes what
1365
+ the bundle no longer carries, and a skill of the same name the member wrote themselves is
1366
+ left alone with a note. A remote without skills answers 404 and nothing happens.
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
+
1358
1418
  ## Multiple coding harnesses, one router
1359
1419
 
1360
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.11.0",
3
+ "version": "0.13.0",
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,89 @@
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
+ isFree: m.input === 0 && m.output === 0,
51
+ createdAtMs: twin?.createdAtMs ?? 0,
52
+ author: entry.id,
53
+ };
54
+ const maxOut = m.maxCompletionTokens ?? twin?.maxCompletionTokens;
55
+ if (maxOut !== undefined) model.maxCompletionTokens = maxOut;
56
+ out.push(model);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export interface StaticCatalogSource {
62
+ /** Models of every enabled entry, rebuilt when the entries or the OpenRouter models change. */
63
+ get(openrouter: readonly CatalogModel[]): CatalogModel[];
64
+ /** The last built set. */
65
+ peek(): CatalogModel[];
66
+ }
67
+
68
+ /** Memoised over the live `cfg.upstreams` array (replaced wholesale on a change) and the OpenRouter models. */
69
+ export function createStaticCatalogSource(cfg: RouterConfig, log?: Logger): StaticCatalogSource {
70
+ let lastEntries: readonly UpstreamEntry[] | null = null;
71
+ let lastBase: readonly CatalogModel[] | null = null;
72
+ let built: CatalogModel[] = [];
73
+ return {
74
+ get(openrouter) {
75
+ if (cfg.upstreams === lastEntries && openrouter === lastBase) return built;
76
+ lastEntries = cfg.upstreams;
77
+ lastBase = openrouter;
78
+ built = cfg.upstreams.filter((u) => u.enabled).flatMap((u) => buildUpstreamModels(u, openrouter));
79
+ 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(", ") });
80
+ return built;
81
+ },
82
+ peek: () => built,
83
+ };
84
+ }
85
+
86
+ /** The published model that an entry names, for clients that need its limits. */
87
+ export function upstreamModel(entry: UpstreamEntry, modelId: string): UpstreamModelConfig | undefined {
88
+ return entry.models.find((m) => m.id === modelId);
89
+ }
@@ -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. */
@@ -31,6 +31,7 @@ import { fileURLToPath } from "node:url";
31
31
  import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
32
32
  import { SCOPE_ENV } from "../context/scope.ts";
33
33
  import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
34
+ import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts";
34
35
  import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
35
36
  import { flagString, type CliArgs } from "./args.ts";
36
37
 
@@ -65,6 +66,8 @@ export interface ConnectOptions {
65
66
  * remote.json records it so a refresh from omp keeps pointing at it.
66
67
  */
67
68
  exePath?: string;
69
+ /** The remote's skills bundle, installed into every configured harness that reads user-level skills. */
70
+ skills?: SkillsBundle;
68
71
  platform: string;
69
72
  pathHas: (bin: string) => boolean;
70
73
  }
@@ -75,6 +78,8 @@ export interface ConnectReport {
75
78
  skipped: string[];
76
79
  envLines: string[];
77
80
  notes: string[];
81
+ /** What the remote's skills bundle did, when there was one. */
82
+ skills?: SkillsInstallReport;
78
83
  }
79
84
 
80
85
  const expand = (raw: string, home: string): string => (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\") ? join(home, raw.slice(1)) : raw);
@@ -360,6 +365,17 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
360
365
  report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
361
366
  report.envLines = [...new Set(report.envLines)];
362
367
 
368
+ // 6b. The remote's skills, into the harnesses configured above that read a
369
+ // user-level skills directory. Hermes, Codex and Aider have none we know of.
370
+ if (o.skills !== undefined) {
371
+ const targets: SkillsTarget[] = [];
372
+ if (report.configured.some((c) => c.startsWith("omp ("))) targets.push({ harness: "omp", dir: join(agentDir, "skills") });
373
+ if (report.configured.some((c) => c.startsWith("Claude Code ("))) targets.push({ harness: "Claude Code", dir: join(claudeDir, "skills") });
374
+ report.skills = installSkills(o.skills, targets, rh, o.dryRun);
375
+ if (report.skills.placed.length > 0) report.configured.push(`skills ${o.skills.version} (${report.skills.placed.join(", ")})`);
376
+ for (const s of report.skills.skipped) report.notes.push(`skill ${s}`);
377
+ }
378
+
363
379
  // 7. Persist the environment.
364
380
  if (o.profile && !o.dryRun) {
365
381
  if (o.platform === "win32") {
@@ -445,6 +461,7 @@ export async function connectCommand(args: CliArgs): Promise<void> {
445
461
  const pathHas = (bin: string): boolean => Bun.which(bin) !== null;
446
462
  const packageDir = await resolvePackageDir();
447
463
  const exePath = executablePath();
464
+ const fetchImpl = fetch;
448
465
  let name = flagString(args, "name") ?? "";
449
466
  let userId = flagString(args, "user-id") ?? "";
450
467
  let device = flagString(args, "device") ?? "";
@@ -476,6 +493,8 @@ export async function connectCommand(args: CliArgs): Promise<void> {
476
493
  // A single-project machine can label every request; a machine with several
477
494
  // repos should leave it off and let the extensions send the workspace's own.
478
495
  const scopeFlag = flagString(args, "scope");
496
+ // The remote's skills for the agents on this machine; a remote without any serves 404.
497
+ const skills = await fetchSkills(url, key, fetchImpl);
479
498
  const report = connectRemote({
480
499
  url,
481
500
  key,
@@ -495,7 +514,9 @@ export async function connectCommand(args: CliArgs): Promise<void> {
495
514
  ...(Number.isFinite(refreshExpires) ? { refreshExpiresAtMs: refreshExpires } : {}),
496
515
  ...(device === "" ? {} : { device }),
497
516
  ...(exePath === null ? {} : { exePath }),
517
+ ...(skills.bundle === null ? {} : { skills: skills.bundle }),
498
518
  });
519
+ if (skills.note !== undefined) report.notes.push(skills.note);
499
520
  if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
500
521
  console.log(`${args.flags.has("dry-run") ? "would write" : "wrote"} ${report.remoteFile}${name === "" ? "" : ` for ${name}`}`);
501
522
  for (const c of report.configured) console.log(` configured ${c}`);
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
17
+ import { fetchSkills } from "./skills.ts";
17
18
  import { homedir } from "node:os";
18
19
  import { dirname, resolve } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
@@ -96,6 +97,8 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
96
97
  const embedded = opts.packageDir === undefined ? await readEmbeddedPackage() : null;
97
98
  const packageDir = opts.packageDir ?? (embedded === null ? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") : materializePackage(rh, embedded));
98
99
  const exePath = opts.remote.executable ?? executablePath() ?? undefined;
100
+ // A refresh is when the team's skills reach a machine that has not re-run connect.
101
+ const skills = await fetchSkills(opts.remote.url, fresh.key, opts.fetchImpl ?? fetch);
99
102
  connectRemote({
100
103
  url: opts.remote.url,
101
104
  key: fresh.key,
@@ -117,6 +120,7 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
117
120
  ...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
118
121
  ...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
119
122
  ...(exePath !== undefined ? { exePath } : {}),
123
+ ...(skills.bundle === null ? {} : { skills: skills.bundle }),
120
124
  // undefined keeps whatever scope the managed models.yml block already carries.
121
125
  });
122
126
  return fresh;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Skills served by a remote router, installed by `connect`.
3
+ *
4
+ * A coding agent is only as good as the instructions it carries, and a team
5
+ * has instructions it wants every member's agent to have: how its shared
6
+ * context works, how to use the router well. The remote serves them as one
7
+ * versioned bundle (`GET <url>/setup/skills`, with the member key), and
8
+ * `connect` writes them into every harness it configures that reads a
9
+ * user-level skills directory — Claude Code's `~/.claude/skills`, omp's
10
+ * `~/.omp/agent/skills`. A manifest under the router home records what was
11
+ * placed, so an update removes what the bundle no longer carries and a skill
12
+ * the member wrote themselves under the same name is never touched.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+
18
+ export interface SkillsBundle {
19
+ /** Changes whenever any file changes; what `connect` reports and remembers. */
20
+ version: string;
21
+ skills: { name: string; files: Record<string, string> }[];
22
+ }
23
+
24
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
25
+ const REL = /^(?!\.)(?!.*\/\.)[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
26
+
27
+ /** Parses a bundle defensively: a malformed one installs nothing rather than something odd. */
28
+ export function parseSkillsBundle(value: unknown): SkillsBundle | null {
29
+ if (typeof value !== "object" || value === null) return null;
30
+ const raw = value as Record<string, unknown>;
31
+ if (typeof raw.version !== "string" || raw.version === "" || !Array.isArray(raw.skills)) return null;
32
+ const skills: SkillsBundle["skills"] = [];
33
+ for (const s of raw.skills) {
34
+ if (typeof s !== "object" || s === null) return null;
35
+ const r = s as Record<string, unknown>;
36
+ if (typeof r.name !== "string" || !NAME.test(r.name) || typeof r.files !== "object" || r.files === null) return null;
37
+ const files: Record<string, string> = {};
38
+ for (const [rel, content] of Object.entries(r.files as Record<string, unknown>)) {
39
+ if (!REL.test(rel) || typeof content !== "string") return null;
40
+ files[rel] = content;
41
+ }
42
+ if (files["SKILL.md"] === undefined) return null;
43
+ skills.push({ name: r.name, files });
44
+ }
45
+ return { version: raw.version, skills };
46
+ }
47
+
48
+ /** The remote's bundle, or null when it serves none (404) or cannot be reached; never throws. */
49
+ export async function fetchSkills(url: string, key: string, fetchImpl: typeof fetch = fetch): Promise<{ bundle: SkillsBundle | null; note?: string }> {
50
+ try {
51
+ const res = await fetchImpl(`${url}/setup/skills`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
52
+ if (res.status === 404) return { bundle: null };
53
+ if (!res.ok) return { bundle: null, note: `skills: ${url}/setup/skills answered ${res.status}; nothing installed` };
54
+ const bundle = parseSkillsBundle(await res.json());
55
+ return bundle === null ? { bundle: null, note: "skills: the remote's bundle was not understood; nothing installed" } : { bundle };
56
+ } catch (err) {
57
+ return { bundle: null, note: `skills: could not fetch ${url}/setup/skills (${err instanceof Error ? err.message : String(err)}); nothing installed` };
58
+ }
59
+ }
60
+
61
+ export interface SkillsTarget {
62
+ /** Shown in the report: "Claude Code", "omp". */
63
+ harness: string;
64
+ /** The harness's user-level skills directory; each skill goes in `<dir>/<name>/`. */
65
+ dir: string;
66
+ }
67
+
68
+ export interface SkillsInstallReport {
69
+ version: string;
70
+ /** "<harness>: <name>" per skill written. */
71
+ placed: string[];
72
+ /** Files from an earlier install the bundle no longer carries. */
73
+ removed: string[];
74
+ /** Skills left alone because the member has their own of that name there. */
75
+ skipped: string[];
76
+ }
77
+
78
+ interface Manifest {
79
+ version: string;
80
+ files: string[];
81
+ }
82
+
83
+ export function skillsManifestPath(routerHome: string): string {
84
+ return join(routerHome, "skills-installed.json");
85
+ }
86
+
87
+ export function readSkillsManifest(routerHome: string): Manifest | null {
88
+ try {
89
+ const raw = JSON.parse(readFileSync(skillsManifestPath(routerHome), "utf8")) as Record<string, unknown>;
90
+ return typeof raw.version === "string" && Array.isArray(raw.files) ? { version: raw.version, files: raw.files.filter((f): f is string => typeof f === "string") } : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ const norm = (p: string): string => p.replaceAll("\\", "/");
97
+
98
+ /**
99
+ * Writes the bundle into each target, records what it placed, removes what a
100
+ * previous install placed that is gone now, and skips a skill directory it did
101
+ * not create. Pure over the file system: no network, so a test can drive it.
102
+ */
103
+ export function installSkills(bundle: SkillsBundle, targets: readonly SkillsTarget[], routerHome: string, dryRun = false): SkillsInstallReport {
104
+ const previous = readSkillsManifest(routerHome);
105
+ const ours = new Set((previous?.files ?? []).map(norm));
106
+ const report: SkillsInstallReport = { version: bundle.version, placed: [], removed: [], skipped: [] };
107
+ const placedFiles: string[] = [];
108
+ for (const t of targets) {
109
+ for (const skill of bundle.skills) {
110
+ const dir = join(t.dir, skill.name);
111
+ const foreign = existsSync(dir) && !readdirSync(dir).some((f) => ours.has(norm(join(dir, f))));
112
+ if (foreign) {
113
+ report.skipped.push(`${t.harness}: ${skill.name} (a skill of that name is already there and was not placed by connect; left alone)`);
114
+ continue;
115
+ }
116
+ for (const [rel, content] of Object.entries(skill.files)) {
117
+ const path = join(dir, ...rel.split("/"));
118
+ placedFiles.push(norm(path));
119
+ if (dryRun) continue;
120
+ mkdirSync(dirname(path), { recursive: true });
121
+ writeFileSync(path, content, "utf8");
122
+ }
123
+ report.placed.push(`${t.harness}: ${skill.name}`);
124
+ }
125
+ }
126
+ const keep = new Set(placedFiles);
127
+ for (const old of ours) {
128
+ if (keep.has(old)) continue;
129
+ report.removed.push(old);
130
+ if (dryRun) continue;
131
+ rmSync(old, { force: true });
132
+ // An emptied skill directory goes too, so the harness does not list a hollow skill.
133
+ const parent = dirname(old);
134
+ try {
135
+ if (readdirSync(parent).length === 0) rmSync(parent, { recursive: true, force: true });
136
+ } catch {
137
+ /* already gone */
138
+ }
139
+ }
140
+ if (!dryRun) {
141
+ mkdirSync(routerHome, { recursive: true });
142
+ writeFileSync(skillsManifestPath(routerHome), `${JSON.stringify({ version: bundle.version, files: placedFiles } satisfies Manifest, null, 2)}\n`, "utf8");
143
+ }
144
+ return report;
145
+ }
@@ -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).
@@ -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>;