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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +60 -0
- package/package.json +1 -1
- package/src/catalog/composite.ts +16 -1
- package/src/catalog/static-catalog.ts +89 -0
- package/src/catalog/types.ts +2 -2
- package/src/cli/connect.ts +21 -0
- package/src/cli/refresh.ts +4 -0
- package/src/cli/skills.ts +145 -0
- package/src/config/apply.ts +5 -1
- package/src/config/defaults.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 +23 -2
- package/src/cost/views.ts +2 -1
- package/src/index.ts +1 -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/skills.test.ts +110 -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.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.
|
|
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
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,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
|
+
}
|
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/cli/connect.ts
CHANGED
|
@@ -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}`);
|
package/src/cli/refresh.ts
CHANGED
|
@@ -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
|
+
}
|
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/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>;
|