auto-model-router 0.1.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/.env.example +24 -0
- package/.github/workflows/publish.yml +40 -0
- package/.omp-plugin/marketplace.json +30 -0
- package/LICENSE +21 -0
- package/README.md +639 -0
- package/bun.lock +32 -0
- package/docs/claude-anthropic-wire.md +116 -0
- package/omp-extension/configure-logic.ts +128 -0
- package/omp-extension/embed-logic.ts +141 -0
- package/omp-extension/router-configure.ts +111 -0
- package/omp-extension/router-embed.ts +118 -0
- package/omp-extension/router-toast.ts +130 -0
- package/omp-extension/toast-logic.ts +136 -0
- package/package.json +56 -0
- package/src/catalog/openrouter-catalog.ts +428 -0
- package/src/catalog/types.ts +104 -0
- package/src/cli/args.ts +105 -0
- package/src/cli/config-cmd.ts +362 -0
- package/src/cli/config-wizard.ts +636 -0
- package/src/cli/explain.ts +167 -0
- package/src/cli/models.ts +240 -0
- package/src/cli/stats.ts +69 -0
- package/src/config/defaults.ts +136 -0
- package/src/config/load.ts +143 -0
- package/src/config/omp-credentials.ts +124 -0
- package/src/config/schema.ts +161 -0
- package/src/config/types.ts +244 -0
- package/src/cost/blended.ts +80 -0
- package/src/cost/forecast.ts +129 -0
- package/src/cost/ledger.ts +291 -0
- package/src/cost/types.ts +148 -0
- package/src/index.ts +93 -0
- package/src/router/cache-control.ts +66 -0
- package/src/router/candidates.ts +246 -0
- package/src/router/classify.ts +329 -0
- package/src/router/escalate.ts +264 -0
- package/src/router/features.ts +225 -0
- package/src/router/index.ts +99 -0
- package/src/router/select.ts +365 -0
- package/src/router/state.ts +118 -0
- package/src/router/tier-plan.ts +151 -0
- package/src/router/types.ts +222 -0
- package/src/server/http.ts +343 -0
- package/src/server/turn.ts +393 -0
- package/src/tokens/estimate.ts +74 -0
- package/src/upstream/openrouter.ts +221 -0
- package/src/upstream/sse-parse.ts +208 -0
- package/src/upstream/types.ts +75 -0
- package/src/util/hash.ts +0 -0
- package/src/util/log.ts +53 -0
- package/src/util/sqlite.ts +140 -0
- package/src/util/sse.ts +23 -0
- package/src/wire/openai/errors.ts +48 -0
- package/src/wire/openai/models.ts +37 -0
- package/src/wire/openai/request.ts +279 -0
- package/src/wire/openai/sink.ts +213 -0
- package/src/wire/types.ts +156 -0
- package/test/catalog.test.ts +319 -0
- package/test/classify.test.ts +269 -0
- package/test/config-wizard.test.ts +482 -0
- package/test/config.test.ts +121 -0
- package/test/configure-logic.test.ts +151 -0
- package/test/cost.test.ts +137 -0
- package/test/embed-logic.test.ts +107 -0
- package/test/escalate.test.ts +223 -0
- package/test/failover.test.ts +494 -0
- package/test/features.test.ts +228 -0
- package/test/fixtures/openrouter-models.json +15340 -0
- package/test/models-yml.test.ts +186 -0
- package/test/omp-credentials.test.ts +185 -0
- package/test/select.test.ts +538 -0
- package/test/sse-parse.test.ts +142 -0
- package/test/tier-plan.test.ts +302 -0
- package/test/toast-logic.test.ts +160 -0
- package/test/tokens.test.ts +160 -0
- package/test/trust-attribution.test.ts +175 -0
- package/test/turn.test.ts +498 -0
- package/test/wire-request.test.ts +297 -0
- package/test/wire-sink.test.ts +179 -0
- package/tools/install.ts +140 -0
- package/tools/mock-openrouter.ts +269 -0
- package/tools/smoke.ts +326 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
6
|
+
import { configInputSchema } from "./schema.ts";
|
|
7
|
+
import { resolveOpenRouterKey, type ResolvedCredential } from "./omp-credentials.ts";
|
|
8
|
+
import type { RouterConfig } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const LOG_LEVELS: readonly RouterConfig["logLevel"][] = ["silent", "error", "warn", "info", "debug"];
|
|
11
|
+
|
|
12
|
+
/** Expands a leading `~` (or `~/`) to the user's home directory. */
|
|
13
|
+
export function resolveTilde(p: string): string {
|
|
14
|
+
if (p === "~") return homedir();
|
|
15
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
20
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursive merge with omp's settings semantics: plain objects merge key by
|
|
25
|
+
* key, arrays REPLACE wholesale (they never union or concatenate), and
|
|
26
|
+
* scalars overwrite. `undefined` override values leave the base untouched.
|
|
27
|
+
*/
|
|
28
|
+
function mergeValue(base: unknown, override: unknown): unknown {
|
|
29
|
+
if (override === undefined) return base;
|
|
30
|
+
if (Array.isArray(override)) return override.slice();
|
|
31
|
+
if (isPlainObject(override) && isPlainObject(base)) {
|
|
32
|
+
const out: Record<string, unknown> = { ...base };
|
|
33
|
+
for (const [k, v] of Object.entries(override)) out[k] = mergeValue(out[k], v);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
return override;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
|
|
40
|
+
return mergeValue(base, override) as RouterConfig;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the effective configuration:
|
|
45
|
+
* DEFAULT_CONFIG
|
|
46
|
+
* <- `$AUTO_MODEL_ROUTER_HOME/config.yml|config.yaml` (or `opts.path`)
|
|
47
|
+
* <- environment (`OPENROUTER_API_KEY`, `AUTO_MODEL_ROUTER_PORT`,
|
|
48
|
+
* `AUTO_MODEL_ROUTER_HOST`, `AUTO_MODEL_ROUTER_LOG`, `AUTO_MODEL_ROUTER_DB`)
|
|
49
|
+
* <- `opts.overrides`
|
|
50
|
+
*
|
|
51
|
+
* A missing OpenRouter API key is NOT an error here: catalog refresh and
|
|
52
|
+
* config work keyless; an embedded router warns at startup and completions
|
|
53
|
+
* fail at dispatch time.
|
|
54
|
+
*/
|
|
55
|
+
export function loadConfig(opts?: { path?: string; overrides?: Partial<RouterConfig> }): RouterConfig {
|
|
56
|
+
const home = resolveTilde(process.env.AUTO_MODEL_ROUTER_HOME ?? "~/.auto-model-router");
|
|
57
|
+
|
|
58
|
+
// Config file, when present.
|
|
59
|
+
const filePath = opts?.path !== undefined
|
|
60
|
+
? resolveTilde(opts.path)
|
|
61
|
+
: [join(home, "config.yml"), join(home, "config.yaml")].find((p) => existsSync(p));
|
|
62
|
+
|
|
63
|
+
let fileInput: unknown = {};
|
|
64
|
+
if (filePath !== undefined) {
|
|
65
|
+
const raw: unknown = parseYaml(readFileSync(filePath, "utf8"));
|
|
66
|
+
if (raw !== null && raw !== undefined) {
|
|
67
|
+
const parsed = configInputSchema.safeParse(raw);
|
|
68
|
+
if (!parsed.success) {
|
|
69
|
+
const lines = parsed.error.issues.map(
|
|
70
|
+
(issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`,
|
|
71
|
+
);
|
|
72
|
+
throw new Error(`Invalid configuration in ${filePath}:\n${lines.join("\n")}`);
|
|
73
|
+
}
|
|
74
|
+
fileInput = parsed.data;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// structuredClone: merge copies only along override paths, so untouched
|
|
79
|
+
// default branches would otherwise be shared (and mutable) by reference.
|
|
80
|
+
let cfg = deepMerge(structuredClone(DEFAULT_CONFIG), fileInput);
|
|
81
|
+
|
|
82
|
+
// Environment overrides.
|
|
83
|
+
const envInput: Record<string, unknown> = {};
|
|
84
|
+
const putSection = (section: string, key: string, value: unknown): void => {
|
|
85
|
+
const s = (envInput[section] ??= {}) as Record<string, unknown>;
|
|
86
|
+
s[key] = value;
|
|
87
|
+
};
|
|
88
|
+
const envApiKey = process.env.OPENROUTER_API_KEY;
|
|
89
|
+
if (envApiKey !== undefined && envApiKey !== "") putSection("openrouter", "apiKey", envApiKey);
|
|
90
|
+
const envPort = process.env.AUTO_MODEL_ROUTER_PORT;
|
|
91
|
+
if (envPort !== undefined && envPort !== "") {
|
|
92
|
+
const port = Number.parseInt(envPort, 10);
|
|
93
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
94
|
+
throw new Error(`AUTO_MODEL_ROUTER_PORT must be an integer between 0 and 65535, got "${envPort}"`);
|
|
95
|
+
}
|
|
96
|
+
putSection("server", "port", port);
|
|
97
|
+
}
|
|
98
|
+
const envHost = process.env.AUTO_MODEL_ROUTER_HOST;
|
|
99
|
+
if (envHost !== undefined && envHost !== "") putSection("server", "host", envHost);
|
|
100
|
+
const envLog = process.env.AUTO_MODEL_ROUTER_LOG;
|
|
101
|
+
if (envLog !== undefined && envLog !== "") {
|
|
102
|
+
if (!(LOG_LEVELS as readonly string[]).includes(envLog)) {
|
|
103
|
+
throw new Error(`AUTO_MODEL_ROUTER_LOG must be one of ${LOG_LEVELS.join(", ")}, got "${envLog}"`);
|
|
104
|
+
}
|
|
105
|
+
envInput.logLevel = envLog;
|
|
106
|
+
}
|
|
107
|
+
const envDb = process.env.AUTO_MODEL_ROUTER_DB;
|
|
108
|
+
if (envDb !== undefined && envDb !== "") putSection("ledger", "path", envDb);
|
|
109
|
+
cfg = deepMerge(cfg, envInput);
|
|
110
|
+
|
|
111
|
+
// Explicit programmatic overrides win last.
|
|
112
|
+
if (opts?.overrides !== undefined) cfg = deepMerge(cfg, opts.overrides);
|
|
113
|
+
|
|
114
|
+
// The default ledger path is expressed relative to the resolved home.
|
|
115
|
+
if (cfg.ledger.path === "") cfg.ledger.path = join(home, "router.db");
|
|
116
|
+
cfg.ledger.path = resolveTilde(cfg.ledger.path);
|
|
117
|
+
|
|
118
|
+
// Last resort for the OpenRouter key: borrow omp's own stored credential, so
|
|
119
|
+
// `/login openrouter` in omp is all the setup this router needs. Explicit
|
|
120
|
+
// config and environment already won above, so this only fills a blank.
|
|
121
|
+
const credential = resolveOpenRouterKey(cfg.openrouter.apiKey);
|
|
122
|
+
cfg.openrouter.apiKey = credential.apiKey;
|
|
123
|
+
apiKeyProvenance.set(cfg, credential);
|
|
124
|
+
|
|
125
|
+
return cfg;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where a config's OpenRouter key came from, for startup logs and `/health`.
|
|
130
|
+
* Keyed weakly off the config object so the provenance never has to travel
|
|
131
|
+
* through `RouterConfig` itself and risk being serialized next to the secret.
|
|
132
|
+
*/
|
|
133
|
+
const apiKeyProvenance = new WeakMap<RouterConfig, ResolvedCredential>();
|
|
134
|
+
|
|
135
|
+
export function apiKeySource(cfg: RouterConfig): ResolvedCredential {
|
|
136
|
+
return (
|
|
137
|
+
apiKeyProvenance.get(cfg) ?? {
|
|
138
|
+
apiKey: cfg.openrouter.apiKey,
|
|
139
|
+
source: cfg.openrouter.apiKey === "" ? "none" : "config",
|
|
140
|
+
detail: cfg.openrouter.apiKey === "" ? "no key configured" : "config",
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Borrows a provider credential from omp's own auth store.
|
|
3
|
+
*
|
|
4
|
+
* The point is that there is exactly ONE OpenRouter key on the machine, owned
|
|
5
|
+
* by omp. Once `/login openrouter` has been run there, auto-model-router picks the key
|
|
6
|
+
* up with no config and no second copy to rotate or leak.
|
|
7
|
+
*
|
|
8
|
+
* The store is opened READ-ONLY and never written: omp owns that file, including
|
|
9
|
+
* OAuth refresh. Every failure path returns null rather than throwing, because a
|
|
10
|
+
* missing or evolving credential store must degrade to "no key" and let the
|
|
11
|
+
* caller report it, not crash the router at startup.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export type CredentialSource = "config" | "env" | "omp-auth-store" | "none";
|
|
20
|
+
|
|
21
|
+
export interface ResolvedCredential {
|
|
22
|
+
apiKey: string;
|
|
23
|
+
source: CredentialSource;
|
|
24
|
+
/** Human-readable provenance for logs and `/health`. Never contains the secret. */
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** omp's agent directory. `PI_CODING_AGENT_DIR` relocates it wholesale. */
|
|
29
|
+
export function ompAgentDir(): string {
|
|
30
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
31
|
+
if (override !== undefined && override !== "") return override;
|
|
32
|
+
return join(homedir(), ".omp", "agent");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ompAuthStorePath(): string {
|
|
36
|
+
return join(ompAgentDir(), "agent.db");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CredentialRow {
|
|
40
|
+
credential_type: string;
|
|
41
|
+
data: string;
|
|
42
|
+
updated_at: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads a usable API key for `provider` out of omp's auth store.
|
|
47
|
+
*
|
|
48
|
+
* Handles both credential shapes omp persists: `api_key` rows carry
|
|
49
|
+
* `{ key, source }`, OAuth rows carry `{ access, refresh, expires }`. An
|
|
50
|
+
* expired OAuth access token is deliberately rejected — refreshing it is omp's
|
|
51
|
+
* job, and sending a stale bearer upstream would just burn a turn on a 401.
|
|
52
|
+
*/
|
|
53
|
+
export function readOmpCredential(provider: string, storePath = ompAuthStorePath()): string | null {
|
|
54
|
+
// A remote broker replaces the local SQLite store entirely, so there is
|
|
55
|
+
// nothing to read here and pretending otherwise would be misleading.
|
|
56
|
+
const broker = process.env.OMP_AUTH_BROKER_URL;
|
|
57
|
+
if (broker !== undefined && broker !== "") return null;
|
|
58
|
+
if (!existsSync(storePath)) return null;
|
|
59
|
+
|
|
60
|
+
let db: Database | null = null;
|
|
61
|
+
try {
|
|
62
|
+
db = new Database(storePath, { readonly: true });
|
|
63
|
+
const rows = db
|
|
64
|
+
.query(
|
|
65
|
+
`SELECT credential_type, data, updated_at
|
|
66
|
+
FROM auth_credentials
|
|
67
|
+
WHERE provider = ? AND disabled_cause IS NULL
|
|
68
|
+
ORDER BY updated_at DESC`,
|
|
69
|
+
)
|
|
70
|
+
.all(provider) as CredentialRow[];
|
|
71
|
+
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(row.data);
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (typeof parsed !== "object" || parsed === null) continue;
|
|
80
|
+
|
|
81
|
+
if (row.credential_type === "api_key" && "key" in parsed && typeof parsed.key === "string" && parsed.key !== "") {
|
|
82
|
+
return parsed.key;
|
|
83
|
+
}
|
|
84
|
+
if (row.credential_type === "oauth" && "access" in parsed && typeof parsed.access === "string" && parsed.access !== "") {
|
|
85
|
+
const expires = "expires" in parsed && typeof parsed.expires === "number" ? parsed.expires : 0;
|
|
86
|
+
if (expires === 0 || expires > Date.now()) return parsed.access;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
} catch {
|
|
91
|
+
// Locked, corrupt, or a schema this version does not understand.
|
|
92
|
+
return null;
|
|
93
|
+
} finally {
|
|
94
|
+
db?.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Full precedence chain for the OpenRouter key. Explicit configuration always
|
|
100
|
+
* beats the borrowed credential, so a project can point at a different account
|
|
101
|
+
* without touching omp.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveOpenRouterKey(configured: string): ResolvedCredential {
|
|
104
|
+
if (configured !== "") {
|
|
105
|
+
const fromEnv = process.env.OPENROUTER_API_KEY;
|
|
106
|
+
const source: CredentialSource = fromEnv !== undefined && fromEnv === configured ? "env" : "config";
|
|
107
|
+
return {
|
|
108
|
+
apiKey: configured,
|
|
109
|
+
source,
|
|
110
|
+
detail: source === "env" ? "OPENROUTER_API_KEY" : "config file",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const borrowed = readOmpCredential("openrouter");
|
|
115
|
+
if (borrowed !== null) {
|
|
116
|
+
return { apiKey: borrowed, source: "omp-auth-store", detail: `omp auth store (${ompAuthStorePath()})` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
apiKey: "",
|
|
121
|
+
source: "none",
|
|
122
|
+
detail: "no key: set OPENROUTER_API_KEY, or run `/login openrouter` inside omp",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Input schema for `$AUTO_MODEL_ROUTER_HOME/config.yml`: a deep partial of
|
|
5
|
+
* `RouterConfig` — every field is optional so the file carries only
|
|
6
|
+
* overrides. Nested objects are strict so a misspelled key fails loudly
|
|
7
|
+
* instead of being silently dropped.
|
|
8
|
+
*
|
|
9
|
+
* Array fields (`profiles`, `probeTiers`, `pin`, `allow`, `deny`,
|
|
10
|
+
* `triggers`) REPLACE the default wholesale on merge; consequently a
|
|
11
|
+
* `profiles` entry must be complete — there is nothing to merge it with.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const tier = z.enum(["trivial", "simple", "moderate", "hard"]);
|
|
15
|
+
const qualityAxis = z.enum(["coding", "agentic", "intelligence"]);
|
|
16
|
+
const logLevel = z.enum(["silent", "error", "warn", "info", "debug"]);
|
|
17
|
+
|
|
18
|
+
const server = z.strictObject({
|
|
19
|
+
host: z.string().min(1).optional(),
|
|
20
|
+
port: z.number().int().min(0).max(65_535).optional(),
|
|
21
|
+
apiKey: z.string().optional(),
|
|
22
|
+
harnessId: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const openrouter = z.strictObject({
|
|
26
|
+
baseUrl: z.string().min(1).optional(),
|
|
27
|
+
apiKey: z.string().optional(),
|
|
28
|
+
referer: z.string().optional(),
|
|
29
|
+
title: z.string().optional(),
|
|
30
|
+
timeoutMs: z.number().positive().optional(),
|
|
31
|
+
catalogTtlMs: z.number().positive().optional(),
|
|
32
|
+
catalogRefreshMs: z.number().nonnegative().optional(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const tierConfig = z.strictObject({
|
|
36
|
+
minQuality: z.number().min(0).max(100).optional(),
|
|
37
|
+
maxInputPerMtok: z.number().nonnegative().optional(),
|
|
38
|
+
maxOutputPerMtok: z.number().nonnegative().optional(),
|
|
39
|
+
qualityExponent: z.number().nonnegative().optional(),
|
|
40
|
+
pin: z.array(z.string()).optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const taskConfig = z.strictObject({
|
|
44
|
+
axis: qualityAxis.optional(),
|
|
45
|
+
minQuality: z.number().min(0).max(100).optional(),
|
|
46
|
+
requireImage: z.boolean().optional(),
|
|
47
|
+
prefer: z.array(z.string()).optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const filters = z.strictObject({
|
|
51
|
+
allow: z.array(z.string()).optional(),
|
|
52
|
+
deny: z.array(z.string()).optional(),
|
|
53
|
+
includeFree: z.boolean().optional(),
|
|
54
|
+
requireToolSupport: z.boolean().optional(),
|
|
55
|
+
minTrust: z.number().min(0).max(1).optional(),
|
|
56
|
+
minTrustSamples: z.number().int().nonnegative().optional(),
|
|
57
|
+
trustScopedByHarness: z.boolean().optional(),
|
|
58
|
+
contextHeadroom: z.number().positive().optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const classifier = z.strictObject({
|
|
62
|
+
ambiguityThreshold: z.number().min(0).max(1).optional(),
|
|
63
|
+
model: z.string().min(1).optional(),
|
|
64
|
+
maxCostFraction: z.number().min(0).max(1).optional(),
|
|
65
|
+
maxCostUsd: z.number().nonnegative().optional(),
|
|
66
|
+
timeoutMs: z.number().positive().optional(),
|
|
67
|
+
cacheSize: z.number().int().nonnegative().optional(),
|
|
68
|
+
toolAxis: qualityAxis.optional(),
|
|
69
|
+
chatAxis: qualityAxis.optional(),
|
|
70
|
+
agenticLoopDepth: z.number().int().nonnegative().optional(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const escalation = z.strictObject({
|
|
74
|
+
enabled: z.boolean().optional(),
|
|
75
|
+
probeTokens: z.number().int().positive().optional(),
|
|
76
|
+
maxHoldMs: z.number().positive().optional(),
|
|
77
|
+
maxAttempts: z.number().int().positive().optional(),
|
|
78
|
+
probeTiers: z.array(tier).optional(),
|
|
79
|
+
triggers: z.array(z.string()).optional(),
|
|
80
|
+
escalateOnLengthStop: z.boolean().optional(),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const hysteresis = z.strictObject({
|
|
84
|
+
holdTurns: z.number().int().nonnegative().optional(),
|
|
85
|
+
holdTurnsAfterEscalation: z.number().int().nonnegative().optional(),
|
|
86
|
+
switchMargin: z.number().positive().optional(),
|
|
87
|
+
cacheWarmTtlMs: z.number().nonnegative().optional(),
|
|
88
|
+
maxDowngradePerTurn: z.number().int().nonnegative().optional(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const cache = z.strictObject({
|
|
92
|
+
injectBreakpoints: z.boolean().optional(),
|
|
93
|
+
maxBreakpoints: z.number().int().positive().optional(),
|
|
94
|
+
minPromptTokens: z.number().int().nonnegative().optional(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const budget = z.strictObject({
|
|
98
|
+
perTurnUsd: z.number().nonnegative().optional(),
|
|
99
|
+
perConversationUsd: z.number().nonnegative().optional(),
|
|
100
|
+
perDayUsd: z.number().nonnegative().optional(),
|
|
101
|
+
onExceeded: z.enum(["downgrade", "reject"]).optional(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const fallbackBlend = z.strictObject({
|
|
105
|
+
inputPerMtok: z.number().nonnegative().optional(),
|
|
106
|
+
outputPerMtok: z.number().nonnegative().optional(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const ledger = z.strictObject({
|
|
110
|
+
path: z.string().optional(),
|
|
111
|
+
blendWindowDays: z.number().positive().optional(),
|
|
112
|
+
blendMinSamples: z.number().int().nonnegative().optional(),
|
|
113
|
+
fallbackBlend: fallbackBlend.optional(),
|
|
114
|
+
conversationTtlMs: z.number().positive().optional(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Complete entries: arrays replace wholesale, so a partial profile would
|
|
118
|
+
// produce an invalid resolved config.
|
|
119
|
+
const profile = z.strictObject({
|
|
120
|
+
id: z.string().min(1),
|
|
121
|
+
name: z.string().min(1),
|
|
122
|
+
maxTier: tier,
|
|
123
|
+
minTier: tier,
|
|
124
|
+
contextWindow: z.number().int().positive(),
|
|
125
|
+
maxTokens: z.number().int().positive(),
|
|
126
|
+
budget: budget.optional(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export const configInputSchema = z.strictObject({
|
|
130
|
+
server: server.optional(),
|
|
131
|
+
openrouter: openrouter.optional(),
|
|
132
|
+
tiers: z
|
|
133
|
+
.strictObject({
|
|
134
|
+
trivial: tierConfig.optional(),
|
|
135
|
+
simple: tierConfig.optional(),
|
|
136
|
+
moderate: tierConfig.optional(),
|
|
137
|
+
hard: tierConfig.optional(),
|
|
138
|
+
})
|
|
139
|
+
.optional(),
|
|
140
|
+
tasks: z
|
|
141
|
+
.strictObject({
|
|
142
|
+
coding: taskConfig.optional(),
|
|
143
|
+
vision: taskConfig.optional(),
|
|
144
|
+
documentation: taskConfig.optional(),
|
|
145
|
+
data: taskConfig.optional(),
|
|
146
|
+
chat: taskConfig.optional(),
|
|
147
|
+
})
|
|
148
|
+
.optional(),
|
|
149
|
+
filters: filters.optional(),
|
|
150
|
+
classifier: classifier.optional(),
|
|
151
|
+
escalation: escalation.optional(),
|
|
152
|
+
hysteresis: hysteresis.optional(),
|
|
153
|
+
cache: cache.optional(),
|
|
154
|
+
budget: budget.optional(),
|
|
155
|
+
profiles: z.array(profile).optional(),
|
|
156
|
+
ledger: ledger.optional(),
|
|
157
|
+
adaptiveTierFloors: z.boolean().optional(),
|
|
158
|
+
logLevel: logLevel.optional(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export type ConfigInput = z.infer<typeof configInputSchema>;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router configuration.
|
|
3
|
+
*
|
|
4
|
+
* Layered: built-in defaults <- `$AUTO_MODEL_ROUTER_HOME/config.yml` <- environment
|
|
5
|
+
* <- CLI flags. Every field is optional on disk; `RouterConfig` is the fully
|
|
6
|
+
* resolved shape the rest of the code consumes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TaskType, Tier } from "../router/types.ts";
|
|
10
|
+
|
|
11
|
+
export type QualityAxis = "coding" | "agentic" | "intelligence";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per-task routing envelope. Task type selects the quality axis, capability
|
|
15
|
+
* filters, and quality floor; the complexity tier's price ceiling and the
|
|
16
|
+
* budget guard still cap cost (task selects, tier budgets).
|
|
17
|
+
*/
|
|
18
|
+
export interface TaskConfig {
|
|
19
|
+
/** Quality axis to score candidates against for this task. */
|
|
20
|
+
axis: QualityAxis;
|
|
21
|
+
/** Minimum quality on that axis, 0-100. Overrides the tier floor when higher. */
|
|
22
|
+
minQuality?: number;
|
|
23
|
+
/** Require image input support. Hard filter for vision tasks. */
|
|
24
|
+
requireImage?: boolean;
|
|
25
|
+
/** Slugs always eligible for this task regardless of quality floor. */
|
|
26
|
+
prefer?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ServerConfig {
|
|
30
|
+
host: string;
|
|
31
|
+
port: number;
|
|
32
|
+
/** Optional bearer required from clients. Unset ⇒ loopback-only, no auth. */
|
|
33
|
+
apiKey?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Harness identity emitted as the `X-Omp-Harness` header in the generated
|
|
36
|
+
* `models.yml` provider block. Lets multiple coding harnesses share one
|
|
37
|
+
* router while keeping per-harness daily budgets and toast scoping. Empty
|
|
38
|
+
* ⇒ no header (single-harness default).
|
|
39
|
+
*/
|
|
40
|
+
harnessId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OpenRouterConfig {
|
|
44
|
+
baseUrl: string;
|
|
45
|
+
/** Resolved from config, then `OPENROUTER_API_KEY`. */
|
|
46
|
+
apiKey: string;
|
|
47
|
+
/** Sent as `HTTP-Referer`, for OpenRouter attribution. */
|
|
48
|
+
referer?: string;
|
|
49
|
+
/** Sent as `X-Title`. */
|
|
50
|
+
title: string;
|
|
51
|
+
/** Per-request timeout, ms. */
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
/** Catalog freshness threshold, ms: refetch on traffic when older than this. */
|
|
54
|
+
catalogTtlMs: number;
|
|
55
|
+
/** Background catalog refresh cadence, ms. 0 disables the periodic refresh. */
|
|
56
|
+
catalogRefreshMs: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Quality/price envelope for one complexity tier. */
|
|
60
|
+
export interface TierConfig {
|
|
61
|
+
/**
|
|
62
|
+
* Minimum quality on the request's chosen axis, 0-100 (Artificial Analysis
|
|
63
|
+
* index as published by OpenRouter). The tier's capability guarantee.
|
|
64
|
+
*/
|
|
65
|
+
minQuality: number;
|
|
66
|
+
/** Hard ceiling on input price, USD per million tokens. Unset ⇒ unbounded. */
|
|
67
|
+
maxInputPerMtok?: number;
|
|
68
|
+
/** Hard ceiling on output price, USD per million tokens. Unset ⇒ unbounded. */
|
|
69
|
+
maxOutputPerMtok?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Exponent on quality in the ranking score. 0 ⇒ pick the cheapest model
|
|
72
|
+
* above the floor (Pareto-style). Higher ⇒ pay for headroom above it.
|
|
73
|
+
*/
|
|
74
|
+
qualityExponent: number;
|
|
75
|
+
/** Slugs always allowed in this tier regardless of the quality floor. */
|
|
76
|
+
pin: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FilterConfig {
|
|
80
|
+
/** Glob patterns; a model must match one. Empty ⇒ allow all. */
|
|
81
|
+
allow: string[];
|
|
82
|
+
/** Glob patterns; matching models are dropped. Applied after `allow`. */
|
|
83
|
+
deny: string[];
|
|
84
|
+
/** Consider zero-price models. Off by default: rate limits make them expensive in retries. */
|
|
85
|
+
includeFree: boolean;
|
|
86
|
+
/** Require `supported_parameters` to include `tools` whenever the request offers tools. */
|
|
87
|
+
requireToolSupport: boolean;
|
|
88
|
+
/** Drop models whose ledger success rate is below this, once `minTrustSamples` is met. */
|
|
89
|
+
minTrust: number;
|
|
90
|
+
/** Attempts required before `minTrust` is enforced against a model. */
|
|
91
|
+
minTrustSamples: number;
|
|
92
|
+
/**
|
|
93
|
+
* Scope model trust to the requesting harness instead of the whole ledger.
|
|
94
|
+
* Off by default: shared trust converges on more samples and keeps the
|
|
95
|
+
* demotion guard effective with a small catalog. Enable only when harnesses
|
|
96
|
+
* route over meaningfully different model sets and each has enough traffic
|
|
97
|
+
* to learn its own reliability.
|
|
98
|
+
*/
|
|
99
|
+
trustScopedByHarness: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Headroom multiplier applied to estimated prompt tokens when checking a
|
|
102
|
+
* model's context window, absorbing token-estimate error and the response.
|
|
103
|
+
*/
|
|
104
|
+
contextHeadroom: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ClassifierConfig {
|
|
108
|
+
/**
|
|
109
|
+
* Heuristic confidence below which the LLM adjudicator is consulted.
|
|
110
|
+
* 0 disables the adjudicator entirely.
|
|
111
|
+
*/
|
|
112
|
+
ambiguityThreshold: number;
|
|
113
|
+
/** Slug used for adjudication. Must be cheap and fast. */
|
|
114
|
+
model: string;
|
|
115
|
+
/** Skip adjudication when it would exceed this fraction of the forecast turn cost. */
|
|
116
|
+
maxCostFraction: number;
|
|
117
|
+
/** Absolute per-call ceiling, USD. */
|
|
118
|
+
maxCostUsd: number;
|
|
119
|
+
timeoutMs: number;
|
|
120
|
+
/** Adjudication verdicts cached per turn fingerprint. */
|
|
121
|
+
cacheSize: number;
|
|
122
|
+
/** Which quality axis to score against when the request offers tools. */
|
|
123
|
+
toolAxis: QualityAxis;
|
|
124
|
+
/** Axis for plain chat requests. */
|
|
125
|
+
chatAxis: QualityAxis;
|
|
126
|
+
/** Tool-loop depth above which the agentic axis takes over. */
|
|
127
|
+
agenticLoopDepth: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface EscalationConfig {
|
|
131
|
+
enabled: boolean;
|
|
132
|
+
/** Hold this many text tokens before committing the stream to the client. */
|
|
133
|
+
probeTokens: number;
|
|
134
|
+
/** Hard ceiling on hold time, ms. Elapsing commits the attempt. */
|
|
135
|
+
maxHoldMs: number;
|
|
136
|
+
/** Max escalation retries per turn. */
|
|
137
|
+
maxAttempts: number;
|
|
138
|
+
/** Tiers eligible for probing. Frontier tiers are usually excluded. */
|
|
139
|
+
probeTiers: Tier[];
|
|
140
|
+
/** Signals that trigger escalation. Narrowing this makes the guard more permissive. */
|
|
141
|
+
triggers: string[];
|
|
142
|
+
/**
|
|
143
|
+
* Escalate when a `length` finish truncated TOOL-CALL ARGUMENTS, leaving
|
|
144
|
+
* structurally unusable output. A length finish on prose never escalates:
|
|
145
|
+
* that is the caller's own `max_tokens`, and a retry truncates identically.
|
|
146
|
+
*/
|
|
147
|
+
escalateOnLengthStop: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface HysteresisConfig {
|
|
151
|
+
/** Turns to hold a tier after committing to it. */
|
|
152
|
+
holdTurns: number;
|
|
153
|
+
/** Turns to hold after an escalation, so a hard sub-task stays on the strong model. */
|
|
154
|
+
holdTurnsAfterEscalation: number;
|
|
155
|
+
/**
|
|
156
|
+
* Switch models only when the expected saving exceeds the forfeited cache
|
|
157
|
+
* discount by this multiple. 1.0 ⇒ break even; higher ⇒ stickier.
|
|
158
|
+
*/
|
|
159
|
+
switchMargin: number;
|
|
160
|
+
/** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */
|
|
161
|
+
cacheWarmTtlMs: number;
|
|
162
|
+
/** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
|
|
163
|
+
maxDowngradePerTurn: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CacheConfig {
|
|
167
|
+
/**
|
|
168
|
+
* Inject Anthropic-style `cache_control` breakpoints. OpenRouter translates
|
|
169
|
+
* them to OpenAI/Google cache primitives, so one mechanism covers every target.
|
|
170
|
+
*/
|
|
171
|
+
injectBreakpoints: boolean;
|
|
172
|
+
/** Max breakpoints per request. Anthropic allows 4. */
|
|
173
|
+
maxBreakpoints: number;
|
|
174
|
+
/** Skip injection below this prompt-token estimate; small prompts cannot cache. */
|
|
175
|
+
minPromptTokens: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface BudgetConfig {
|
|
179
|
+
/** Reject or downgrade when a turn's cold forecast exceeds this, USD. */
|
|
180
|
+
perTurnUsd?: number;
|
|
181
|
+
/** Force the cheapest viable tier once a conversation exceeds this, USD. */
|
|
182
|
+
perConversationUsd?: number;
|
|
183
|
+
/** Rolling 24h ceiling, USD. */
|
|
184
|
+
perDayUsd?: number;
|
|
185
|
+
/** At the ceiling: drop to the cheapest viable model, or fail the request outright. */
|
|
186
|
+
onExceeded: "downgrade" | "reject";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A virtual model exposed to clients. `auto` is the general profile; the
|
|
191
|
+
* others bias the same machinery toward cost or quality.
|
|
192
|
+
*/
|
|
193
|
+
export interface ProfileConfig {
|
|
194
|
+
/** Model id as clients see it, e.g. `auto`. */
|
|
195
|
+
id: string;
|
|
196
|
+
/** Display name in omp's model picker. */
|
|
197
|
+
name: string;
|
|
198
|
+
/** Clamp classification to at most this tier. */
|
|
199
|
+
maxTier: Tier;
|
|
200
|
+
/** Floor classification at this tier. */
|
|
201
|
+
minTier: Tier;
|
|
202
|
+
/** Context window advertised to omp; drives its compaction threshold. */
|
|
203
|
+
contextWindow: number;
|
|
204
|
+
/** Max output tokens advertised to omp. */
|
|
205
|
+
maxTokens: number;
|
|
206
|
+
/** Per-profile budget overrides. */
|
|
207
|
+
budget?: Partial<BudgetConfig>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface LedgerConfig {
|
|
211
|
+
/** SQLite path. Defaults to `$AUTO_MODEL_ROUTER_HOME/router.db`. */
|
|
212
|
+
path: string;
|
|
213
|
+
/** Window for the blended rate published to omp, days. */
|
|
214
|
+
blendWindowDays: number;
|
|
215
|
+
/** Requests required before the measured blend replaces `fallbackBlend`. */
|
|
216
|
+
blendMinSamples: number;
|
|
217
|
+
/** Blend used before enough samples exist, USD per million tokens. */
|
|
218
|
+
fallbackBlend: { inputPerMtok: number; outputPerMtok: number };
|
|
219
|
+
/** Drop conversation state untouched for longer than this, ms. */
|
|
220
|
+
conversationTtlMs: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface RouterConfig {
|
|
224
|
+
server: ServerConfig;
|
|
225
|
+
openrouter: OpenRouterConfig;
|
|
226
|
+
tiers: Record<Tier, TierConfig>;
|
|
227
|
+
tasks: Record<TaskType, TaskConfig>;
|
|
228
|
+
filters: FilterConfig;
|
|
229
|
+
classifier: ClassifierConfig;
|
|
230
|
+
escalation: EscalationConfig;
|
|
231
|
+
hysteresis: HysteresisConfig;
|
|
232
|
+
cache: CacheConfig;
|
|
233
|
+
budget: BudgetConfig;
|
|
234
|
+
profiles: ProfileConfig[];
|
|
235
|
+
ledger: LedgerConfig;
|
|
236
|
+
/**
|
|
237
|
+
* Derive each tier's quality floor from the models actually available at
|
|
238
|
+
* every catalog refresh, relaxing (never tightening) the configured floors.
|
|
239
|
+
* Without this, a narrow OpenRouter guardrail leaves every tier above
|
|
240
|
+
* `trivial` permanently empty and the router is stuck on the cheapest model.
|
|
241
|
+
*/
|
|
242
|
+
adaptiveTierFloors: boolean;
|
|
243
|
+
logLevel: "silent" | "error" | "warn" | "info" | "debug";
|
|
244
|
+
}
|