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.
Files changed (83) hide show
  1. package/.env.example +24 -0
  2. package/.github/workflows/publish.yml +40 -0
  3. package/.omp-plugin/marketplace.json +30 -0
  4. package/LICENSE +21 -0
  5. package/README.md +639 -0
  6. package/bun.lock +32 -0
  7. package/docs/claude-anthropic-wire.md +116 -0
  8. package/omp-extension/configure-logic.ts +128 -0
  9. package/omp-extension/embed-logic.ts +141 -0
  10. package/omp-extension/router-configure.ts +111 -0
  11. package/omp-extension/router-embed.ts +118 -0
  12. package/omp-extension/router-toast.ts +130 -0
  13. package/omp-extension/toast-logic.ts +136 -0
  14. package/package.json +56 -0
  15. package/src/catalog/openrouter-catalog.ts +428 -0
  16. package/src/catalog/types.ts +104 -0
  17. package/src/cli/args.ts +105 -0
  18. package/src/cli/config-cmd.ts +362 -0
  19. package/src/cli/config-wizard.ts +636 -0
  20. package/src/cli/explain.ts +167 -0
  21. package/src/cli/models.ts +240 -0
  22. package/src/cli/stats.ts +69 -0
  23. package/src/config/defaults.ts +136 -0
  24. package/src/config/load.ts +143 -0
  25. package/src/config/omp-credentials.ts +124 -0
  26. package/src/config/schema.ts +161 -0
  27. package/src/config/types.ts +244 -0
  28. package/src/cost/blended.ts +80 -0
  29. package/src/cost/forecast.ts +129 -0
  30. package/src/cost/ledger.ts +291 -0
  31. package/src/cost/types.ts +148 -0
  32. package/src/index.ts +93 -0
  33. package/src/router/cache-control.ts +66 -0
  34. package/src/router/candidates.ts +246 -0
  35. package/src/router/classify.ts +329 -0
  36. package/src/router/escalate.ts +264 -0
  37. package/src/router/features.ts +225 -0
  38. package/src/router/index.ts +99 -0
  39. package/src/router/select.ts +365 -0
  40. package/src/router/state.ts +118 -0
  41. package/src/router/tier-plan.ts +151 -0
  42. package/src/router/types.ts +222 -0
  43. package/src/server/http.ts +343 -0
  44. package/src/server/turn.ts +393 -0
  45. package/src/tokens/estimate.ts +74 -0
  46. package/src/upstream/openrouter.ts +221 -0
  47. package/src/upstream/sse-parse.ts +208 -0
  48. package/src/upstream/types.ts +75 -0
  49. package/src/util/hash.ts +0 -0
  50. package/src/util/log.ts +53 -0
  51. package/src/util/sqlite.ts +140 -0
  52. package/src/util/sse.ts +23 -0
  53. package/src/wire/openai/errors.ts +48 -0
  54. package/src/wire/openai/models.ts +37 -0
  55. package/src/wire/openai/request.ts +279 -0
  56. package/src/wire/openai/sink.ts +213 -0
  57. package/src/wire/types.ts +156 -0
  58. package/test/catalog.test.ts +319 -0
  59. package/test/classify.test.ts +269 -0
  60. package/test/config-wizard.test.ts +482 -0
  61. package/test/config.test.ts +121 -0
  62. package/test/configure-logic.test.ts +151 -0
  63. package/test/cost.test.ts +137 -0
  64. package/test/embed-logic.test.ts +107 -0
  65. package/test/escalate.test.ts +223 -0
  66. package/test/failover.test.ts +494 -0
  67. package/test/features.test.ts +228 -0
  68. package/test/fixtures/openrouter-models.json +15340 -0
  69. package/test/models-yml.test.ts +186 -0
  70. package/test/omp-credentials.test.ts +185 -0
  71. package/test/select.test.ts +538 -0
  72. package/test/sse-parse.test.ts +142 -0
  73. package/test/tier-plan.test.ts +302 -0
  74. package/test/toast-logic.test.ts +160 -0
  75. package/test/tokens.test.ts +160 -0
  76. package/test/trust-attribution.test.ts +175 -0
  77. package/test/turn.test.ts +498 -0
  78. package/test/wire-request.test.ts +297 -0
  79. package/test/wire-sink.test.ts +179 -0
  80. package/tools/install.ts +140 -0
  81. package/tools/mock-openrouter.ts +269 -0
  82. package/tools/smoke.ts +326 -0
  83. package/tsconfig.json +23 -0
@@ -0,0 +1,186 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parse as parseYaml } from "yaml";
3
+
4
+ import { loadConfig } from "../src/config/load.ts";
5
+ import {
6
+ BEGIN_GUARD,
7
+ END_GUARD,
8
+ assertUsableModelsYaml,
9
+ renderProviderBlock,
10
+ spliceProviderBlock,
11
+ } from "../src/cli/config-cmd.ts";
12
+
13
+ const cfg = loadConfig({});
14
+ const BLOCK = renderProviderBlock(cfg, null);
15
+
16
+ /** A models.yml with other providers, comments, and non-default indentation. */
17
+ const EXISTING = `providers:
18
+ fastflowlm:
19
+ api: openai-completions
20
+ auth: none
21
+ baseUrl: http://127.0.0.1:52625/v1
22
+ models:
23
+ # contextWindow must match FLM's runtime KV cache, NOT the
24
+ # model's architectural limit.
25
+ - contextWindow: 32768
26
+ id: qwen3.6-moe:35b-a3b
27
+ name: Qwen3.6 MoE 35B-A3B (NPU)
28
+ ollama:
29
+ api: openai-responses
30
+ auth: none
31
+ baseUrl: http://127.0.0.1:11434/v1
32
+ discovery:
33
+ type: ollama
34
+ `;
35
+
36
+ function providersOf(text: string): Record<string, unknown> {
37
+ const parsed: unknown = parseYaml(text);
38
+ if (typeof parsed !== "object" || parsed === null || !("providers" in parsed)) {
39
+ throw new Error("no providers mapping");
40
+ }
41
+ const providers = parsed.providers;
42
+ if (typeof providers !== "object" || providers === null) throw new Error("providers is not a mapping");
43
+ return providers as Record<string, unknown>;
44
+ }
45
+
46
+ function countOf(text: string, needle: string): number {
47
+ return text.split(needle).length - 1;
48
+ }
49
+
50
+ describe("renderProviderBlock", () => {
51
+ test("emits costs in USD per MILLION tokens, not per token", () => {
52
+ const providers = providersOf(`providers:\n${BLOCK.split("\n").map((l) => (l === "" ? "" : ` ${l}`)).join("\n")}\n`);
53
+ const router = providers["auto-model-router"];
54
+ expect(typeof router).toBe("object");
55
+ const parsed: unknown = parseYaml(BLOCK);
56
+ expect(typeof parsed).toBe("object");
57
+ // Fallback blend is dollars-per-million; a per-token figure would be ~1e-6.
58
+ expect(cfg.ledger.fallbackBlend.inputPerMtok).toBeGreaterThan(0.01);
59
+ expect(BLOCK).toContain(`input: ${cfg.ledger.fallbackBlend.inputPerMtok}`);
60
+ });
61
+
62
+ test("declares one model per configured profile", () => {
63
+ for (const profile of cfg.profiles) expect(BLOCK).toContain(`id: ${profile.id}`);
64
+ });
65
+
66
+ test("advertises a keyless openai-compatible provider", () => {
67
+ expect(BLOCK).toContain("api: openai-completions");
68
+ expect(BLOCK).toContain("auth: none");
69
+ expect(BLOCK).toContain("/v1");
70
+ });
71
+ });
72
+
73
+ describe("spliceProviderBlock", () => {
74
+ test("preserves every original line and comment", () => {
75
+ const result = spliceProviderBlock(EXISTING, BLOCK);
76
+ expect(result.action).toBe("inserted");
77
+ const after = result.text.split("\n");
78
+ for (const line of EXISTING.split("\n")) {
79
+ if (line.trim() === "") continue;
80
+ expect(after).toContain(line);
81
+ }
82
+ expect(result.text).toContain("# model's architectural limit.");
83
+ });
84
+
85
+ test("leaves the pre-existing providers intact and adds ours", () => {
86
+ const result = spliceProviderBlock(EXISTING, BLOCK);
87
+ const providers = providersOf(result.text);
88
+ expect(Object.keys(providers).sort()).toEqual(["auto-model-router", "fastflowlm", "ollama"]);
89
+ });
90
+
91
+ test("is idempotent: a second run replaces rather than duplicates", () => {
92
+ const once = spliceProviderBlock(EXISTING, BLOCK);
93
+ const twice = spliceProviderBlock(once.text, BLOCK);
94
+ expect(twice.action).toBe("replaced");
95
+ expect(countOf(twice.text, BEGIN_GUARD)).toBe(1);
96
+ expect(countOf(twice.text, END_GUARD)).toBe(1);
97
+ expect(countOf(twice.text, "auto-model-router:")).toBe(1);
98
+ providersOf(twice.text);
99
+ });
100
+
101
+ test("refreshed cost figures replace the old ones in place", () => {
102
+ const first = spliceProviderBlock(EXISTING, renderProviderBlock(cfg, null));
103
+ const updated = spliceProviderBlock(
104
+ first.text,
105
+ renderProviderBlock(cfg, {
106
+ inputPerMtok: 0.4242,
107
+ outputPerMtok: 1.2345,
108
+ cacheReadPerMtok: 0.0424,
109
+ cacheWritePerMtok: 0.5303,
110
+ sampleCount: 99,
111
+ windowDays: 7,
112
+ }),
113
+ );
114
+ expect(updated.text).toContain("0.4242");
115
+ expect(countOf(updated.text, BEGIN_GUARD)).toBe(1);
116
+ providersOf(updated.text);
117
+ });
118
+
119
+ test("adds a providers mapping when the file has none", () => {
120
+ const result = spliceProviderBlock("# just a comment\n", BLOCK);
121
+ expect(result.text).toContain("# just a comment");
122
+ expect(countOf(result.text, "providers:")).toBe(1);
123
+ expect(Object.keys(providersOf(result.text))).toContain("auto-model-router");
124
+ });
125
+
126
+ test("creates a whole file from empty input", () => {
127
+ const result = spliceProviderBlock("", BLOCK);
128
+ expect(result.action).toBe("created");
129
+ expect(Object.keys(providersOf(result.text))).toContain("auto-model-router");
130
+ });
131
+
132
+ test("survives a UTF-8 BOM without producing a duplicate providers key", () => {
133
+ // A BOM made the first line read as "\uFEFFproviders:", so the top-level
134
+ // key was missed and a second one appended -- which makes omp discard the
135
+ // entire file.
136
+ const result = spliceProviderBlock(`\uFEFF${EXISTING}`, BLOCK);
137
+ expect(result.text.startsWith("\uFEFF")).toBe(true);
138
+ const lines = result.text.split("\n");
139
+ expect(lines.filter((l) => /^\uFEFF?providers\s*:/.test(l))).toHaveLength(1);
140
+ expect(Object.keys(providersOf(result.text)).sort()).toEqual(["auto-model-router", "fastflowlm", "ollama"]);
141
+ });
142
+
143
+ test("preserves CRLF line endings", () => {
144
+ const crlf = EXISTING.replaceAll("\n", "\r\n");
145
+ const result = spliceProviderBlock(crlf, BLOCK);
146
+ expect(result.text).toContain("\r\n");
147
+ // No stray bare LF introduced by the inserted block.
148
+ expect(result.text.replaceAll("\r\n", "")).not.toContain("\n");
149
+ providersOf(result.text);
150
+ });
151
+
152
+ test("matches the existing child indentation", () => {
153
+ // EXISTING indents providers' children by four spaces; mixing widths
154
+ // under one mapping is invalid YAML.
155
+ const result = spliceProviderBlock(EXISTING, BLOCK);
156
+ const begin = result.text.split("\n").find((l) => l.includes(BEGIN_GUARD));
157
+ expect(begin).toBeDefined();
158
+ expect(begin?.startsWith(" #")).toBe(true);
159
+ });
160
+
161
+ test("a two-space file gets two-space children", () => {
162
+ const twoSpace = "providers:\n ollama:\n auth: none\n";
163
+ const result = spliceProviderBlock(twoSpace, BLOCK);
164
+ const begin = result.text.split("\n").find((l) => l.includes(BEGIN_GUARD));
165
+ expect(begin?.startsWith(" #")).toBe(true);
166
+ providersOf(result.text);
167
+ });
168
+ });
169
+
170
+ describe("assertUsableModelsYaml", () => {
171
+ test("accepts a correctly spliced result", () => {
172
+ expect(() => assertUsableModelsYaml(spliceProviderBlock(EXISTING, BLOCK).text)).not.toThrow();
173
+ });
174
+
175
+ test("rejects a duplicate providers key, which YAML treats as fatal", () => {
176
+ expect(() => assertUsableModelsYaml("providers:\n a:\n auth: none\nproviders:\n b:\n auth: none\n")).toThrow();
177
+ });
178
+
179
+ test("rejects a file without our provider", () => {
180
+ expect(() => assertUsableModelsYaml("providers:\n ollama:\n auth: none\n")).toThrow();
181
+ });
182
+
183
+ test("rejects a file with no providers mapping", () => {
184
+ expect(() => assertUsableModelsYaml("something: else\n")).toThrow();
185
+ });
186
+ });
@@ -0,0 +1,185 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { readOmpCredential, resolveOpenRouterKey } from "../src/config/omp-credentials.ts";
8
+
9
+ /**
10
+ * omp's real `auth_credentials` DDL, copied verbatim from a live
11
+ * `~/.omp/agent/agent.db`. Tests must fail if we drift from the actual schema,
12
+ * so this is not a simplified stand-in.
13
+ */
14
+ const SCHEMA = `
15
+ CREATE TABLE auth_credentials (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ provider TEXT NOT NULL,
18
+ credential_type TEXT NOT NULL,
19
+ data TEXT NOT NULL,
20
+ disabled_cause TEXT DEFAULT NULL,
21
+ identity_key TEXT DEFAULT NULL,
22
+ created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
23
+ updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
24
+ )`;
25
+
26
+ const dirs: string[] = [];
27
+ const savedEnv = new Map<string, string | undefined>();
28
+
29
+ function setEnv(key: string, value: string | undefined): void {
30
+ if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]);
31
+ if (value === undefined) delete process.env[key];
32
+ else process.env[key] = value;
33
+ }
34
+
35
+ interface Row {
36
+ provider: string;
37
+ type: string;
38
+ data: unknown;
39
+ disabled?: string;
40
+ updatedAt?: number;
41
+ }
42
+
43
+ function storeWith(rows: Row[]): string {
44
+ const dir = mkdtempSync(join(tmpdir(), "ompr-cred-"));
45
+ dirs.push(dir);
46
+ const path = join(dir, "agent.db");
47
+ const db = new Database(path);
48
+ db.exec(SCHEMA);
49
+ const insert = db.query(
50
+ "INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, updated_at) VALUES (?, ?, ?, ?, ?)",
51
+ );
52
+ for (const r of rows) {
53
+ insert.run(r.provider, r.type, JSON.stringify(r.data), r.disabled ?? null, r.updatedAt ?? Math.floor(Date.now() / 1000));
54
+ }
55
+ db.close();
56
+ return path;
57
+ }
58
+
59
+ afterEach(() => {
60
+ for (const [key, value] of savedEnv) {
61
+ if (value === undefined) delete process.env[key];
62
+ else process.env[key] = value;
63
+ }
64
+ savedEnv.clear();
65
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
66
+ });
67
+
68
+ describe("readOmpCredential", () => {
69
+ test("reads an api_key credential in omp's stored shape", () => {
70
+ // Matches the real `ollama-cloud` row: {"key": "...", "source": "..."}
71
+ const path = storeWith([{ provider: "openrouter", type: "api_key", data: { key: "sk-or-real", source: "login" } }]);
72
+ expect(readOmpCredential("openrouter", path)).toBe("sk-or-real");
73
+ });
74
+
75
+ test("returns null for a provider that is not logged in", () => {
76
+ const path = storeWith([{ provider: "anthropic", type: "oauth", data: { access: "x", expires: Date.now() + 1e6 } }]);
77
+ expect(readOmpCredential("openrouter", path)).toBeNull();
78
+ });
79
+
80
+ test("accepts an unexpired oauth access token", () => {
81
+ const path = storeWith([
82
+ { provider: "openrouter", type: "oauth", data: { access: "tok-live", refresh: "r", expires: Date.now() + 600_000 } },
83
+ ]);
84
+ expect(readOmpCredential("openrouter", path)).toBe("tok-live");
85
+ });
86
+
87
+ test("rejects an expired oauth token rather than burning a turn on a 401", () => {
88
+ // Refreshing is omp's job; we hold the store read-only.
89
+ const path = storeWith([
90
+ { provider: "openrouter", type: "oauth", data: { access: "tok-stale", refresh: "r", expires: Date.now() - 1000 } },
91
+ ]);
92
+ expect(readOmpCredential("openrouter", path)).toBeNull();
93
+ });
94
+
95
+ test("skips a disabled credential", () => {
96
+ const path = storeWith([
97
+ { provider: "openrouter", type: "api_key", data: { key: "sk-or-dead" }, disabled: "revoked" },
98
+ ]);
99
+ expect(readOmpCredential("openrouter", path)).toBeNull();
100
+ });
101
+
102
+ test("prefers the most recently updated credential", () => {
103
+ const path = storeWith([
104
+ { provider: "openrouter", type: "api_key", data: { key: "sk-or-old" }, updatedAt: 1000 },
105
+ { provider: "openrouter", type: "api_key", data: { key: "sk-or-new" }, updatedAt: 2000 },
106
+ ]);
107
+ expect(readOmpCredential("openrouter", path)).toBe("sk-or-new");
108
+ });
109
+
110
+ test("survives a missing, unreadable, or unexpected store", () => {
111
+ expect(readOmpCredential("openrouter", join(tmpdir(), "definitely-absent-agent.db"))).toBeNull();
112
+ const dir = mkdtempSync(join(tmpdir(), "ompr-cred-"));
113
+ dirs.push(dir);
114
+ const garbage = join(dir, "agent.db");
115
+ writeFileSync(garbage, "this is definitely not a sqlite database");
116
+ // A corrupt or foreign file must degrade to "no key", never throw.
117
+ expect(() => readOmpCredential("openrouter", garbage)).not.toThrow();
118
+ expect(readOmpCredential("openrouter", garbage)).toBeNull();
119
+ });
120
+
121
+ test("skips a row whose data is not valid JSON", () => {
122
+ const dir = mkdtempSync(join(tmpdir(), "ompr-cred-"));
123
+ dirs.push(dir);
124
+ const path = join(dir, "agent.db");
125
+ const db = new Database(path);
126
+ db.exec(SCHEMA);
127
+ db.query("INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?)").run(
128
+ "openrouter",
129
+ "api_key",
130
+ "{not json",
131
+ );
132
+ db.close();
133
+ expect(readOmpCredential("openrouter", path)).toBeNull();
134
+ });
135
+
136
+ test("declines to read a local store when a remote auth broker is configured", () => {
137
+ const path = storeWith([{ provider: "openrouter", type: "api_key", data: { key: "sk-or-local" } }]);
138
+ setEnv("OMP_AUTH_BROKER_URL", "https://broker.example");
139
+ // Broker mode replaces the local store; reading it would be a stale lie.
140
+ expect(readOmpCredential("openrouter", path)).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("resolveOpenRouterKey", () => {
145
+ test("explicit configuration wins over the borrowed credential", () => {
146
+ setEnv("OPENROUTER_API_KEY", undefined);
147
+ const resolved = resolveOpenRouterKey("sk-or-explicit");
148
+ expect(resolved.apiKey).toBe("sk-or-explicit");
149
+ expect(resolved.source).toBe("config");
150
+ });
151
+
152
+ test("attributes a key that came from the environment", () => {
153
+ setEnv("OPENROUTER_API_KEY", "sk-or-from-env");
154
+ const resolved = resolveOpenRouterKey("sk-or-from-env");
155
+ expect(resolved.source).toBe("env");
156
+ });
157
+
158
+ test("reports an actionable message when nothing is configured", () => {
159
+ setEnv("OPENROUTER_API_KEY", undefined);
160
+ setEnv("PI_CODING_AGENT_DIR", mkdtempSync(join(tmpdir(), "ompr-empty-agent-")));
161
+ dirs.push(process.env.PI_CODING_AGENT_DIR ?? "");
162
+ const resolved = resolveOpenRouterKey("");
163
+ expect(resolved.apiKey).toBe("");
164
+ expect(resolved.source).toBe("none");
165
+ expect(resolved.detail).toContain("/login openrouter");
166
+ });
167
+
168
+ test("borrows omp's credential when nothing else is configured", () => {
169
+ setEnv("OPENROUTER_API_KEY", undefined);
170
+ const dir = mkdtempSync(join(tmpdir(), "ompr-agentdir-"));
171
+ dirs.push(dir);
172
+ const db = new Database(join(dir, "agent.db"));
173
+ db.exec(SCHEMA);
174
+ db.query("INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?)").run(
175
+ "openrouter",
176
+ "api_key",
177
+ JSON.stringify({ key: "sk-or-borrowed", source: "login" }),
178
+ );
179
+ db.close();
180
+ setEnv("PI_CODING_AGENT_DIR", dir);
181
+ const resolved = resolveOpenRouterKey("");
182
+ expect(resolved.apiKey).toBe("sk-or-borrowed");
183
+ expect(resolved.source).toBe("omp-auth-store");
184
+ });
185
+ });