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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named upstream entries as they arrive (a file, an env-built patch, a dashboard patch)
|
|
3
|
+
* carry only what the author set; every client reads a complete record, so the optional
|
|
4
|
+
* fields are filled here, at load and after every live apply.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { UpstreamEntry } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Fills an upstream entry's optional fields, so every client reads a complete record. */
|
|
10
|
+
export function completeUpstreamEntry(raw: Record<string, unknown>): UpstreamEntry {
|
|
11
|
+
const kind = raw.kind as UpstreamEntry["kind"];
|
|
12
|
+
return {
|
|
13
|
+
id: raw.id as string,
|
|
14
|
+
kind,
|
|
15
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : true,
|
|
16
|
+
baseUrl: raw.baseUrl as string,
|
|
17
|
+
apiKey: typeof raw.apiKey === "string" ? raw.apiKey : "",
|
|
18
|
+
apiVersion: typeof raw.apiVersion === "string" ? raw.apiVersion : "2024-10-21",
|
|
19
|
+
headers: (raw.headers as Record<string, string> | undefined) ?? {},
|
|
20
|
+
timeoutMs: typeof raw.timeoutMs === "number" ? raw.timeoutMs : 600_000,
|
|
21
|
+
rateLimitCooldownMs: typeof raw.rateLimitCooldownMs === "number" ? raw.rateLimitCooldownMs : 60_000,
|
|
22
|
+
quotaCooldownMs: typeof raw.quotaCooldownMs === "number" ? raw.quotaCooldownMs : 15 * 60_000,
|
|
23
|
+
models: (raw.models as UpstreamEntry["models"] | undefined) ?? [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Replaces the live list's entries with completed ones, in place, when a patch touched them. */
|
|
28
|
+
export function completeUpstreams(cfg: { upstreams: UpstreamEntry[] }): void {
|
|
29
|
+
const completed = cfg.upstreams.map((u) => completeUpstreamEntry(u as unknown as Record<string, unknown>));
|
|
30
|
+
cfg.upstreams.splice(0, cfg.upstreams.length, ...completed);
|
|
31
|
+
}
|
package/src/cost/report.ts
CHANGED
|
@@ -137,7 +137,28 @@ const USD = "COALESCE(reported_usd, predicted_usd)";
|
|
|
137
137
|
const PT = "json_extract(usage, '$.promptTokens')";
|
|
138
138
|
const CT = "json_extract(usage, '$.cachedTokens')";
|
|
139
139
|
const COMP = "json_extract(usage, '$.completionTokens')";
|
|
140
|
-
|
|
140
|
+
/** Named upstream ids the ledger's provider derivation knows; set by createProviders from the live config. */
|
|
141
|
+
let knownUpstreamIds: readonly string[] = [];
|
|
142
|
+
export function setKnownUpstreamIds(ids: readonly string[]): void {
|
|
143
|
+
knownUpstreamIds = ids.filter((id) => /^[a-z0-9][a-z0-9-]{0,31}$/.test(id));
|
|
144
|
+
}
|
|
145
|
+
export function knownUpstreams(): readonly string[] {
|
|
146
|
+
return knownUpstreamIds;
|
|
147
|
+
}
|
|
148
|
+
/** The provider of a slug: its namespace when that names a known upstream, else OpenRouter's own. */
|
|
149
|
+
export function providerOfSlug(slug: string): string {
|
|
150
|
+
if (slug.startsWith("ollama/")) return "ollama";
|
|
151
|
+
const cut = slug.indexOf("/");
|
|
152
|
+
if (cut > 0) {
|
|
153
|
+
const head = slug.slice(0, cut);
|
|
154
|
+
if (knownUpstreamIds.includes(head)) return head;
|
|
155
|
+
}
|
|
156
|
+
return "openrouter";
|
|
157
|
+
}
|
|
158
|
+
/** SQL twin of providerOfSlug; ids are validated to a slug alphabet so they can be inlined. */
|
|
159
|
+
function providerCase(): string {
|
|
160
|
+
return `CASE WHEN slug LIKE 'ollama/%' THEN 'ollama' ${knownUpstreamIds.map((id) => `WHEN slug LIKE '${id}/%' THEN '${id}'`).join(" ")} ELSE 'openrouter' END`;
|
|
161
|
+
}
|
|
141
162
|
const STREAMED = "ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL";
|
|
142
163
|
const EST = "json_extract(usage, '$.cachedEstimated') = 1";
|
|
143
164
|
/** Rows a forecast can be judged on: a reported cost, a prediction, clean and kept, not a side call. */
|
|
@@ -277,7 +298,7 @@ export function buildUsageReport(
|
|
|
277
298
|
|
|
278
299
|
const windowSpend = t.spend;
|
|
279
300
|
const providers = (
|
|
280
|
-
db.query(`SELECT ${
|
|
301
|
+
db.query(`SELECT ${providerCase()} AS key, ${ROW_SELECT} FROM ledger WHERE ${where} GROUP BY key ORDER BY spend DESC`).all(bind) as RawRow[]
|
|
281
302
|
).map((r) => toRow(r, windowSpend));
|
|
282
303
|
|
|
283
304
|
const modelRows = db
|
package/src/cost/views.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* columns and accept a read-only database handle.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { providerOfSlug } from "./report.ts";
|
|
11
12
|
import type { Database } from "bun:sqlite";
|
|
12
13
|
import { harnessFilter } from "./report.ts";
|
|
13
14
|
|
|
@@ -120,7 +121,7 @@ export function exportRows(db: Database, sinceMs: number, harness: HarnessScope)
|
|
|
120
121
|
day: r.day,
|
|
121
122
|
harnessId: r.harness_id,
|
|
122
123
|
slug: r.slug,
|
|
123
|
-
provider: r.slug
|
|
124
|
+
provider: providerOfSlug(r.slug),
|
|
124
125
|
dispatches: r.dispatches,
|
|
125
126
|
promptTokens: r.prompt_tokens,
|
|
126
127
|
cachedTokens: r.cached_tokens,
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ Usage: auto-model-router <command> [options]
|
|
|
28
28
|
stats Show routed spend, per-model share, and escalation rates
|
|
29
29
|
report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
|
|
30
30
|
export One row per day, harness and model as CSV (--json for rows)
|
|
31
|
-
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH)
|
|
31
|
+
connect Point this machine at a remote router (--url with --key[, --refresh-token] or --setup-token <one-time token from a team>; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment and, from the compiled executable, PATH; the remote's skills are installed for Claude Code and omp)
|
|
32
32
|
refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
|
|
33
33
|
token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
|
|
34
34
|
models Show what each complexity tier would consider, and why
|
package/src/lib.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
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";
|
|
19
20
|
export type { DeepPartial } from "./config/load.ts";
|
|
20
21
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
21
22
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
|
@@ -24,5 +25,6 @@ export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam,
|
|
|
24
25
|
export { createLedger } from "./cost/ledger.ts";
|
|
25
26
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
26
27
|
export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
|
|
28
|
+
export { parseSkillsBundle, type SkillsBundle } from "./cli/skills.ts";
|
|
27
29
|
export type { RequestPolicy } from "./wire/types.ts";
|
|
28
30
|
export type { Ledger, LedgerEntry } from "./cost/types.ts";
|
package/src/server/http.ts
CHANGED
|
@@ -226,7 +226,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
226
226
|
if (cfg.ledger.path !== ":memory:") mkdirSync(dirname(cfg.ledger.path), { recursive: true });
|
|
227
227
|
const db = openDb(cfg.ledger.path);
|
|
228
228
|
const ledger = createLedger(db, cfg);
|
|
229
|
-
const
|
|
229
|
+
const providers = createProviders(cfg, db, log);
|
|
230
|
+
const { upstream, catalog, ollama, ollamaServing, ollamaUsage, ollamaCostScale } = providers;
|
|
230
231
|
const conversations = createConversationStore(db);
|
|
231
232
|
const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
|
|
232
233
|
const context = createBridgeFromConfig(cfg, db);
|
|
@@ -272,6 +273,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
272
273
|
if (cfg.ollama.enabled) log.warn("no OpenRouter key: routing over Ollama Cloud models only (OpenRouter's catalog is read for metadata, never served)");
|
|
273
274
|
else log.warn("OPENROUTER_API_KEY is not set and Ollama is off; /v1/chat/completions will fail at dispatch time");
|
|
274
275
|
}
|
|
276
|
+
for (const u of cfg.upstreams) {
|
|
277
|
+
if (!u.enabled) continue;
|
|
278
|
+
log.info(`named upstream enabled: ${u.id}`, { kind: u.kind, baseUrl: u.baseUrl, models: u.models.length, apiKeyConfigured: u.apiKey !== "" });
|
|
279
|
+
}
|
|
275
280
|
if (cfg.ollama.enabled) {
|
|
276
281
|
log.info("ollama cloud upstream enabled", {
|
|
277
282
|
baseUrl: cfg.ollama.baseUrl,
|
|
@@ -639,7 +644,12 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
639
644
|
apiKeyConfigured: cfg.openrouter.apiKey !== "",
|
|
640
645
|
// Which upstreams turns can actually be served from: OpenRouter needs
|
|
641
646
|
// its key; Ollama needs to be on and out of cooldown.
|
|
642
|
-
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollamaServing() ? ["ollama"] : [])],
|
|
647
|
+
serving: [...(cfg.openrouter.apiKey !== "" ? ["openrouter"] : []), ...(ollamaServing() ? ["ollama"] : []), ...providers.namedServing()],
|
|
648
|
+
// Named direct upstreams: never the key. `available` is each one's breaker.
|
|
649
|
+
upstreams: cfg.upstreams.map((u) => {
|
|
650
|
+
const client = providers.named(u.id);
|
|
651
|
+
return { id: u.id, kind: u.kind, enabled: u.enabled, baseUrl: u.baseUrl, apiKeyConfigured: u.apiKey !== "", models: u.models.length, available: client?.available() ?? true, cooldownUntilMs: client?.cooldownUntilMs() ?? null, lastTrip: client?.lastTrip() ?? null };
|
|
652
|
+
}),
|
|
643
653
|
// Provenance only; never the key itself.
|
|
644
654
|
apiKeySource: apiKeySource(cfg).source,
|
|
645
655
|
// Provenance only; never the agentdox token itself.
|
package/src/server/providers.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Database } from "bun:sqlite";
|
|
8
8
|
import { createCompositeCatalog } from "../catalog/composite.ts";
|
|
9
|
+
import { createStaticCatalogSource } from "../catalog/static-catalog.ts";
|
|
10
|
+
import { createAnthropicClient } from "../upstream/anthropic.ts";
|
|
11
|
+
import { createCompatClient, type NamedUpstreamClient } from "../upstream/compat.ts";
|
|
9
12
|
import { createOllamaCatalog } from "../catalog/ollama-catalog.ts";
|
|
10
13
|
import { createCatalog } from "../catalog/openrouter-catalog.ts";
|
|
11
14
|
import type { CatalogSource } from "../catalog/types.ts";
|
|
@@ -13,6 +16,7 @@ import type { RouterConfig } from "../config/types.ts";
|
|
|
13
16
|
import { createMultiUpstream } from "../upstream/multi.ts";
|
|
14
17
|
import { createOllamaClient, type OllamaClient } from "../upstream/ollama.ts";
|
|
15
18
|
import { createLedger } from "../cost/ledger.ts";
|
|
19
|
+
import { setKnownUpstreamIds } from "../cost/report.ts";
|
|
16
20
|
import { createOllamaUsageSource, type OllamaUsageSource } from "../upstream/ollama-usage.ts";
|
|
17
21
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
18
22
|
import type { UpstreamClient } from "../upstream/types.ts";
|
|
@@ -29,6 +33,10 @@ export interface Providers {
|
|
|
29
33
|
ollamaUsage: OllamaUsageSource;
|
|
30
34
|
/** Multiplier that brings the ledger's Ollama estimate in line with the plan meter; 1 until calibrated. */
|
|
31
35
|
ollamaCostScale: () => number;
|
|
36
|
+
/** The client for a named upstream id, built on first use; undefined for an id not configured. */
|
|
37
|
+
named(id: string): NamedUpstreamClient | undefined;
|
|
38
|
+
/** Ids of the named upstreams that can take a turn now: enabled, keyed, out of cooldown. */
|
|
39
|
+
namedServing(): string[];
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function createProviders(cfg: RouterConfig, db: Database, log: Logger = createLogger(cfg.logLevel)): Providers {
|
|
@@ -53,18 +61,43 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
53
61
|
// estimate can be scaled to what ollama.com actually bills.
|
|
54
62
|
calibration: { db, ledgerUsd: () => ledgerForCalibration.providerSpendSince?.("ollama/", 0) ?? 0, planCreditsOverrideUsd: cfg.ollama.planCreditsUsd },
|
|
55
63
|
});
|
|
64
|
+
// Named upstreams (OpenAI, Azure, Anthropic, vLLM…): a client per id, built when
|
|
65
|
+
// first needed and kept — its breaker state must survive config reloads — while
|
|
66
|
+
// the entry it reads is looked up live, so a changed key or URL applies at once.
|
|
67
|
+
const namedClients = new Map<string, NamedUpstreamClient>();
|
|
68
|
+
const named = (id: string): NamedUpstreamClient | undefined => {
|
|
69
|
+
const entry = cfg.upstreams.find((u) => u.id === id);
|
|
70
|
+
if (entry === undefined) return undefined;
|
|
71
|
+
let client = namedClients.get(id);
|
|
72
|
+
if (client === undefined) {
|
|
73
|
+
client = entry.kind === "anthropic" ? createAnthropicClient(cfg, id) : createCompatClient(cfg, id);
|
|
74
|
+
namedClients.set(id, client);
|
|
75
|
+
}
|
|
76
|
+
return client;
|
|
77
|
+
};
|
|
78
|
+
const namedServingOne = (id: string): boolean => {
|
|
79
|
+
const entry = cfg.upstreams.find((u) => u.id === id);
|
|
80
|
+
if (entry === undefined || !entry.enabled || entry.apiKey === "" && entry.kind !== "openai") return entry !== undefined && entry.enabled && (named(id)?.available() ?? false);
|
|
81
|
+
return named(id)?.available() ?? false;
|
|
82
|
+
};
|
|
83
|
+
const namedServing = (): string[] => cfg.upstreams.filter((u) => u.enabled && namedServingOne(u.id)).map((u) => u.id);
|
|
84
|
+
const staticCatalog = createStaticCatalogSource(cfg, log);
|
|
85
|
+
setKnownUpstreamIds(cfg.upstreams.map((u) => u.id));
|
|
56
86
|
return {
|
|
57
|
-
upstream: createMultiUpstream(openrouter, ollama),
|
|
87
|
+
upstream: createMultiUpstream(openrouter, ollama, named, () => cfg.upstreams.map((u) => u.id)),
|
|
58
88
|
catalog: createCompositeCatalog(openrouterCatalog, createOllamaCatalog(cfg.ollama, log, fetch, db), { available: ollamaServing, cooldownUntilMs: () => ollama.cooldownUntilMs(), lastTrip: () => ollama.lastTrip() }, {
|
|
59
89
|
costBias: cfg.ollama.costBias,
|
|
60
90
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
61
91
|
usage: ollamaUsage,
|
|
62
92
|
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
63
93
|
serveOpenRouter: () => cfg.openrouter.apiKey !== "",
|
|
94
|
+
named: { models: (base) => staticCatalog.get(base), serving: namedServingOne },
|
|
64
95
|
}),
|
|
65
96
|
ollama,
|
|
66
97
|
ollamaServing,
|
|
67
98
|
ollamaUsage,
|
|
68
99
|
ollamaCostScale: () => ollamaUsage.calibration()?.factor ?? 1,
|
|
100
|
+
named,
|
|
101
|
+
namedServing,
|
|
69
102
|
};
|
|
70
103
|
}
|