pi-bifrost 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/routing.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+
4
+ export type RoutingStrategy =
5
+ | "first"
6
+ | "cheapest"
7
+ | "cheapest_input"
8
+ | "cheapest_output"
9
+ | "largest_context"
10
+ | "random"
11
+ | "fastest";
12
+
13
+ export interface RouteRule {
14
+ pattern: string;
15
+ model: string;
16
+ }
17
+
18
+ export function modelKey(model: Model<Api> | undefined): string {
19
+ if (!model) return "none";
20
+ return `${model.provider}/${model.id}`;
21
+ }
22
+
23
+ /** Sum of input + output token costs per 1M tokens. Does not include cache read/write costs. */
24
+ export function modelCost(model: Model<Api>): number {
25
+ return model.cost.input + model.cost.output;
26
+ }
27
+
28
+ /** Input token cost only. */
29
+ export function modelInputCost(model: Model<Api>): number {
30
+ return model.cost.input;
31
+ }
32
+
33
+ /** Output token cost only. */
34
+ export function modelOutputCost(model: Model<Api>): number {
35
+ return model.cost.output;
36
+ }
37
+
38
+ /** Context window size. */
39
+ export function modelContextSize(model: Model<Api>): number {
40
+ return model.contextWindow;
41
+ }
42
+
43
+ export function findOneModel(
44
+ ctx: ExtensionContext,
45
+ pattern: string,
46
+ ): Model<Api> | undefined {
47
+ if (!pattern) return undefined;
48
+
49
+ if (pattern.includes("/")) {
50
+ const [provider, ...idParts] = pattern.split("/");
51
+ const id = idParts.join("/");
52
+ return ctx.modelRegistry.find(provider, id);
53
+ }
54
+
55
+ const lower = pattern.toLowerCase();
56
+ const available = ctx.modelRegistry.getAvailable();
57
+ return available.find(
58
+ (m) =>
59
+ m.id.toLowerCase().includes(lower) ||
60
+ m.provider.toLowerCase().includes(lower),
61
+ );
62
+ }
63
+
64
+ export function findCandidates(
65
+ ctx: ExtensionContext,
66
+ pattern: string | string[] | undefined,
67
+ ): Model<Api>[] {
68
+ if (!pattern) return [];
69
+
70
+ const candidates: Model<Api>[] = [];
71
+ const seen = new Set<string>();
72
+ const patterns = Array.isArray(pattern) ? pattern : [pattern];
73
+ // Resolve once — avoid N getAvailable() calls for N substring patterns.
74
+ const available = ctx.modelRegistry.getAvailable();
75
+
76
+ for (const p of patterns) {
77
+ if (p.includes("/")) {
78
+ const model = findOneModel(ctx, p);
79
+ if (model) {
80
+ const key = modelKey(model);
81
+ if (!seen.has(key)) {
82
+ seen.add(key);
83
+ candidates.push(model);
84
+ }
85
+ }
86
+ } else {
87
+ const lower = p.toLowerCase();
88
+ for (const m of available) {
89
+ if (
90
+ !seen.has(modelKey(m)) &&
91
+ (m.id.toLowerCase().includes(lower) ||
92
+ m.provider.toLowerCase().includes(lower))
93
+ ) {
94
+ seen.add(modelKey(m));
95
+ candidates.push(m);
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ return candidates;
102
+ }
103
+
104
+ export function selectModel(
105
+ candidates: Model<Api>[],
106
+ strategy: RoutingStrategy,
107
+ ): Model<Api> | undefined {
108
+ if (candidates.length === 0) return undefined;
109
+
110
+ switch (strategy) {
111
+ case "cheapest":
112
+ return [...candidates].sort((a, b) => modelCost(a) - modelCost(b))[0];
113
+ case "cheapest_input":
114
+ return [...candidates].sort((a, b) => modelInputCost(a) - modelInputCost(b))[0];
115
+ case "cheapest_output":
116
+ return [...candidates].sort((a, b) => modelOutputCost(a) - modelOutputCost(b))[0];
117
+ case "largest_context":
118
+ return [...candidates].sort((a, b) => modelContextSize(b) - modelContextSize(a))[0];
119
+ case "random":
120
+ return candidates[Math.floor(Math.random() * candidates.length)];
121
+ default:
122
+ // "first", "fastest" — list order is assumed meaningful.
123
+ return candidates[0];
124
+ }
125
+ }
126
+
127
+ export function resolveModel(
128
+ ctx: ExtensionContext,
129
+ pattern: string | string[] | undefined,
130
+ strategy: RoutingStrategy,
131
+ ): Model<Api> | undefined {
132
+ return selectModel(findCandidates(ctx, pattern), strategy);
133
+ }
134
+
135
+ export function getStrategy(
136
+ categoryStrategies: Record<string, RoutingStrategy> | undefined,
137
+ fallbackStrategy: RoutingStrategy | undefined,
138
+ category: string,
139
+ ): RoutingStrategy {
140
+ return categoryStrategies?.[category] ?? fallbackStrategy ?? "first";
141
+ }
142
+
143
+ export function classify(text: string, rules: readonly RouteRule[]): string | undefined {
144
+ for (const rule of rules) {
145
+ try {
146
+ const re = new RegExp(rule.pattern, "i");
147
+ if (re.test(text)) return rule.model;
148
+ } catch (err) {
149
+ console.error(`[bifrost] invalid regex "${rule.pattern}": ${err}`);
150
+ }
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ // ── Tier heuristics ───────────────────────────────────────────
156
+
157
+ /**
158
+ * Cost thresholds for tier assignment during `/bifrost init`.
159
+ * Models with cost above FRONTIER are suggested as frontier;
160
+ * below ECONOMICAL as economical. Everything else is uncategorized
161
+ * and the user assigns manually. No name-based guessing — naming
162
+ * conventions change; cost is the stable signal.
163
+ */
164
+ const FRONTIER_COST_THRESHOLD = 5; // $/1M tokens (input + output)
165
+ const ECONOMICAL_COST_THRESHOLD = 1;
166
+
167
+ /** Assign a model to a tier based solely on token cost. */
168
+ export function guessTier(model: Model<Api>): "frontier" | "economical" | undefined {
169
+ const cost = (model.cost?.input ?? 0) + (model.cost?.output ?? 0);
170
+ if (cost > FRONTIER_COST_THRESHOLD) return "frontier";
171
+ if (cost < ECONOMICAL_COST_THRESHOLD) return "economical";
172
+ return undefined;
173
+ }
package/schema.json ADDED
@@ -0,0 +1,189 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://pi.dev/schemas/bifrost.json",
4
+ "title": "Bifrost Model Router Config",
5
+ "description": "Configuration for the pi-bifrost query-aware model router. Maps user prompts to model tiers via regex rules and an optional LLM classifier. Merged from extension defaults → global → project-local → root.",
6
+ "type": "object",
7
+ "properties": {
8
+ "enabled": {
9
+ "type": "boolean",
10
+ "description": "Enable or disable the model router. When disabled, all requests use the default model.",
11
+ "default": true
12
+ },
13
+ "default": {
14
+ "type": "string",
15
+ "description": "Model tier to use when no rule matches and the classifier returns no category. Must match a key in 'models'.",
16
+ "default": "economical",
17
+ "examples": ["economical", "frontier"]
18
+ },
19
+ "strategy": {
20
+ "type": "string",
21
+ "enum": ["first", "cheapest", "cheapest_input", "cheapest_output", "largest_context", "random", "fastest"],
22
+ "description": "Model selection strategy. 'first'/'fastest' picks first candidate (list should be pre-sorted). 'cheapest' sorts by input+output cost. 'cheapest_input' by input cost. 'cheapest_output' by output cost. 'largest_context' by context window. 'random' picks randomly.",
23
+ "default": "first"
24
+ },
25
+ "categoryStrategies": {
26
+ "type": "object",
27
+ "description": "Per-tier strategy overrides. Keys must match tier names in 'models'. If a tier is not listed here, 'strategy' is used.",
28
+ "default": { "economical": "cheapest" },
29
+ "additionalProperties": {
30
+ "type": "string",
31
+ "enum": ["first", "cheapest", "cheapest_input", "cheapest_output", "largest_context", "random", "fastest"]
32
+ }
33
+ },
34
+ "models": {
35
+ "type": "object",
36
+ "description": "Model tiers. Each key is a tier name (e.g. 'frontier', 'economical'). Values are provider/model patterns — either a single string or an ordered array of candidate patterns.",
37
+ "default": {},
38
+ "additionalProperties": {
39
+ "oneOf": [
40
+ { "type": "string" },
41
+ {
42
+ "type": "array",
43
+ "items": { "type": "string" }
44
+ }
45
+ ]
46
+ },
47
+ "examples": [
48
+ {
49
+ "frontier": ["opencode/deepseek-v4-flash-free"],
50
+ "economical": ["ollama/qwen2.5-coder:latest", "lmstudio/openai/gpt-oss-20b"]
51
+ }
52
+ ]
53
+ },
54
+ "rules": {
55
+ "type": "array",
56
+ "description": "Regex-based routing rules applied in order. First matching rule determines the model tier. Case-insensitive matching. If no rule matches, the 'default' tier is used. Prefer using bifrost-routes.json for project-specific rules — it overrides this field.",
57
+ "x-aiHints": {
58
+ "danger": "Wrong regex can misroute all requests. Test with /bifrost preview <prompt> before committing.",
59
+ "atomic": true
60
+ },
61
+ "items": {
62
+ "$ref": "#/definitions/RouteRule"
63
+ }
64
+ },
65
+ "classifier": {
66
+ "$ref": "#/definitions/ClassifierConfig"
67
+ },
68
+ "cache": {
69
+ "$ref": "#/definitions/CacheConfig"
70
+ },
71
+ "debug": {
72
+ "$ref": "#/definitions/DebugConfig"
73
+ }
74
+ },
75
+ "definitions": {
76
+ "RouteRule": {
77
+ "type": "object",
78
+ "required": ["pattern", "model"],
79
+ "additionalProperties": false,
80
+ "properties": {
81
+ "pattern": {
82
+ "type": "string",
83
+ "description": "JavaScript regex pattern (without flags, double-escaped). Matched case-insensitively against the user's prompt. Example: '\\\\b(debug|fix|refactor)\\\\b'"
84
+ },
85
+ "model": {
86
+ "type": "string",
87
+ "description": "Model tier name. Must match a key in the 'models' map."
88
+ }
89
+ }
90
+ },
91
+ "ClassifierConfig": {
92
+ "type": "object",
93
+ "description": "LLM-based classifier that inspects user prompts and selects a tier before falling back to regex rules. Set 'enabled: false' to use regex-only routing.",
94
+ "required": ["model"],
95
+ "additionalProperties": false,
96
+ "properties": {
97
+ "enabled": {
98
+ "type": "boolean",
99
+ "description": "Enable the LLM classifier. When disabled, only regex rules are used.",
100
+ "default": true
101
+ },
102
+ "model": {
103
+ "type": "string",
104
+ "description": "Model pattern for the classifier. Use the cheapest model you trust — each classification costs tokens. Example: 'ollama/qwen2.5-coder:latest'"
105
+ },
106
+ "endpoint": {
107
+ "type": "string",
108
+ "description": "Optional base URL for the classifier when the model is not in pi's model registry. Example: 'http://127.0.0.1:1234/v1'. When set, only method='direct' works for that endpoint."
109
+ },
110
+ "method": {
111
+ "type": "string",
112
+ "enum": ["direct", "subprocess", "auto"],
113
+ "description": "How to invoke the classifier. 'direct' uses a fast OpenAI-compatible HTTP call (needs compatible API). 'subprocess' spawns a fresh pi CLI (slower but supports any provider). 'auto' tries direct first, falls back to subprocess.",
114
+ "default": "auto"
115
+ },
116
+ "maxTokens": {
117
+ "type": "integer",
118
+ "minimum": 1,
119
+ "maximum": 100,
120
+ "description": "Max output tokens for the classifier. Keep it low — only need the category name back.",
121
+ "default": 20
122
+ },
123
+ "temperature": {
124
+ "type": "number",
125
+ "minimum": 0,
126
+ "maximum": 2,
127
+ "description": "Temperature for classifier sampling. 0 for deterministic category selection.",
128
+ "default": 0
129
+ },
130
+ "fallbackToRegex": {
131
+ "type": "boolean",
132
+ "description": "Fall back to regex rules when the classifier fails or returns an unknown category.",
133
+ "default": true
134
+ },
135
+ "systemPrompt": {
136
+ "type": "string",
137
+ "description": "Custom system prompt for the classifier. Override to change how categories are presented to the LLM.",
138
+ "default": "You are a routing classifier. Pick the category that best matches the task complexity and type. Respond with only the exact category name. No explanation, no punctuation."
139
+ }
140
+ }
141
+ },
142
+ "CacheConfig": {
143
+ "type": "object",
144
+ "description": "Fuzzy classification cache. Repeated or similar prompts reuse the cached category instead of calling the classifier again.",
145
+ "additionalProperties": false,
146
+ "properties": {
147
+ "enabled": {
148
+ "type": "boolean",
149
+ "description": "Enable the classification cache.",
150
+ "default": true
151
+ },
152
+ "maxEntries": {
153
+ "type": "integer",
154
+ "minimum": 1,
155
+ "maximum": 10000,
156
+ "description": "Maximum number of cached entries. Oldest entries (by last use) are evicted first.",
157
+ "default": 500
158
+ },
159
+ "threshold": {
160
+ "type": "number",
161
+ "minimum": 0,
162
+ "maximum": 1,
163
+ "description": "Jaccard similarity threshold for fuzzy cache matches. 0 = any prompt matches. 1 = only exact token matches.",
164
+ "default": 0.85
165
+ },
166
+ "path": {
167
+ "type": "string",
168
+ "description": "Custom path for the cache file. Absolute, ~-prefixed, or relative to project root. Default: .pi/bifrost-cache.jsonl"
169
+ }
170
+ }
171
+ },
172
+ "DebugConfig": {
173
+ "type": "object",
174
+ "description": "Structured debug logging. Appends JSONL lines to a file for performance analysis and troubleshooting. Disabled by default.",
175
+ "additionalProperties": false,
176
+ "properties": {
177
+ "enabled": {
178
+ "type": "boolean",
179
+ "description": "Enable debug logging.",
180
+ "default": false
181
+ },
182
+ "path": {
183
+ "type": "string",
184
+ "description": "Custom path for the debug log file. Default: .pi/bifrost-debug.jsonl"
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ normalize,
5
+ lookupCache,
6
+ touchCacheEntry,
7
+ findCachedCategory,
8
+ updateCache,
9
+ cachePath,
10
+ } from "../cache.ts";
11
+
12
+ describe("cache", () => {
13
+ describe("normalize", () => {
14
+ it("lowercases, strips punctuation, sorts tokens", () => {
15
+ assert.equal(normalize("Hello, World!"), "hello world");
16
+ assert.equal(normalize(" Plan the architecture? "), "architecture plan the");
17
+ });
18
+
19
+ it("returns empty string for empty input", () => {
20
+ assert.equal(normalize(""), "");
21
+ });
22
+
23
+ it("preserves non-Latin characters", () => {
24
+ const result = normalize("调试 内存泄漏");
25
+ assert.ok(result.includes("调试"));
26
+ assert.ok(result.includes("内存泄漏"));
27
+ });
28
+
29
+ it("normalizes empty string for Jaccard edge case", () => {
30
+ // Empty-vs-empty should still produce cache-viable normalized form
31
+ assert.equal(normalize("!@#$%"), "");
32
+ });
33
+ });
34
+
35
+ describe("lookupCache", () => {
36
+ it("returns entry for exact match (no mutation)", () => {
37
+ const entries = [
38
+ { normalized: "hello world", category: "economical", lastUsed: 1, hits: 5 },
39
+ ];
40
+ const result = lookupCache(entries, "Hello World!", 0.85);
41
+ assert.ok(result);
42
+ assert.equal(result!.category, "economical");
43
+ assert.equal(entries[0].hits, 5);
44
+ assert.equal(entries[0].lastUsed, 1);
45
+ });
46
+
47
+ it("returns entry for fuzzy match above threshold", () => {
48
+ const entries = [
49
+ { normalized: "hello world today is nice", category: "economical", lastUsed: 1, hits: 0 },
50
+ ];
51
+ const result = lookupCache(entries, "hello world today is good", 0.5);
52
+ assert.ok(result);
53
+ assert.equal(result!.category, "economical");
54
+ assert.equal(entries[0].lastUsed, 1);
55
+ });
56
+
57
+ it("returns undefined when nothing matches", () => {
58
+ const entries = [
59
+ { normalized: "hello world", category: "economical", lastUsed: 1, hits: 0 },
60
+ ];
61
+ assert.equal(lookupCache(entries, "plan architecture", 0.85), undefined);
62
+ });
63
+ });
64
+
65
+ describe("touchCacheEntry", () => {
66
+ it("mutates lastUsed and hits", () => {
67
+ const entry = { normalized: "hello", category: "economical", lastUsed: 100, hits: 3 };
68
+ touchCacheEntry(entry);
69
+ assert.ok(entry.lastUsed > 100);
70
+ assert.equal(entry.hits, 4);
71
+ });
72
+ });
73
+
74
+ describe("findCachedCategory", () => {
75
+ it("returns exact match", () => {
76
+ const entries = [
77
+ { normalized: "hello world", category: "economical", lastUsed: 1, hits: 0 },
78
+ ];
79
+ assert.equal(findCachedCategory(entries, "Hello World!", 0.85), "economical");
80
+ });
81
+
82
+ it("returns fuzzy match above threshold", () => {
83
+ const entries = [
84
+ { normalized: "hello world today is nice", category: "economical", lastUsed: 1, hits: 0 },
85
+ ];
86
+ assert.equal(
87
+ findCachedCategory(entries, "hello world today is good", 0.5),
88
+ "economical",
89
+ );
90
+ });
91
+
92
+ it("returns undefined when nothing matches", () => {
93
+ const entries = [
94
+ { normalized: "hello world", category: "economical", lastUsed: 1, hits: 0 },
95
+ ];
96
+ assert.equal(findCachedCategory(entries, "plan architecture", 0.85), undefined);
97
+ });
98
+
99
+ it("updates hits and lastUsed on match", () => {
100
+ const entries = [
101
+ { normalized: "hello", category: "economical", lastUsed: 1, hits: 1 },
102
+ ];
103
+ findCachedCategory(entries, "hello", 0.85);
104
+ assert.equal(entries[0].hits, 2);
105
+ assert.ok(entries[0].lastUsed > 1);
106
+ });
107
+ });
108
+
109
+ describe("updateCache", () => {
110
+ it("adds new entry", () => {
111
+ const entries = updateCache([], "hello", "economical", 10);
112
+ assert.equal(entries.length, 1);
113
+ assert.equal(entries[0].normalized, "hello");
114
+ assert.equal(entries[0].category, "economical");
115
+ });
116
+
117
+ it("updates existing entry", () => {
118
+ let entries = updateCache([], "hello", "economical", 10);
119
+ entries = updateCache(entries, "hello", "frontier", 10);
120
+ assert.equal(entries.length, 1);
121
+ assert.equal(entries[0].category, "frontier");
122
+ });
123
+
124
+ it("evicts oldest entries over cap", () => {
125
+ let entries: ReturnType<typeof updateCache> = [];
126
+ for (let i = 0; i < 5; i++) {
127
+ entries = updateCache(entries, `token${i}`, "economical", 3);
128
+ }
129
+ assert.equal(entries.length, 3);
130
+ assert.ok(!entries.some((e) => e.normalized === "token0"));
131
+ });
132
+ });
133
+
134
+ describe("cachePath", () => {
135
+ it("defaults to .pi/bifrost-cache.jsonl under cwd", () => {
136
+ assert.equal(cachePath("/project"), "/project/.pi/bifrost-cache.jsonl");
137
+ });
138
+
139
+ it("expands leading tilde", () => {
140
+ process.env.HOME = "/home/user";
141
+ assert.equal(cachePath("/project", "~/cache.jsonl"), "/home/user/cache.jsonl");
142
+ });
143
+
144
+ it("joins relative path to cwd", () => {
145
+ assert.equal(cachePath("/project", "cache.jsonl"), "/project/cache.jsonl");
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ categoryLabel,
5
+ classificationPrompt,
6
+ extractCategory,
7
+ } from "../classifier.ts";
8
+
9
+ describe("classifier", () => {
10
+ describe("categoryLabel", () => {
11
+ it("returns category name unchanged", () => {
12
+ assert.equal(categoryLabel("frontier"), "frontier");
13
+ assert.equal(categoryLabel("economical"), "economical");
14
+ assert.equal(categoryLabel("local"), "local");
15
+ });
16
+ });
17
+
18
+ describe("classificationPrompt", () => {
19
+ it("lists categories by name", () => {
20
+ const prompt = classificationPrompt(["frontier", "economical"], "hello");
21
+ assert.ok(prompt.includes("frontier, economical"));
22
+ assert.ok(prompt.includes("Request: hello"));
23
+ });
24
+ });
25
+
26
+ describe("extractCategory", () => {
27
+ it("extracts exact category name", () => {
28
+ assert.equal(extractCategory("frontier", ["frontier", "economical"]), "frontier");
29
+ });
30
+
31
+ it("is case-insensitive", () => {
32
+ assert.equal(extractCategory("Frontier", ["frontier", "economical"]), "frontier");
33
+ });
34
+
35
+ it("handles surrounding whitespace", () => {
36
+ assert.equal(extractCategory(" economical ", ["frontier", "economical"]), "economical");
37
+ });
38
+
39
+ it("returns undefined for non-matching text", () => {
40
+ assert.equal(extractCategory("unknown", ["frontier", "economical"]), undefined);
41
+ });
42
+
43
+ it("does not substring match", () => {
44
+ // "not economical" should not match "economical"
45
+ assert.equal(extractCategory("not economical", ["frontier", "economical"]), undefined);
46
+ });
47
+ });
48
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ // resolveTierDisplay is not exported from commands.ts — it's internal.
5
+ // The handlers (handleInit, handleBenchmark, handlePreview) are also internal.
6
+ // These are tested via integration tests.
7
+ // This file verifies the routing helpers that commands.ts depends on.
8
+
9
+ import {
10
+ getStrategy,
11
+ selectModel,
12
+ findCandidates,
13
+ modelKey,
14
+ guessTier,
15
+ } from "../routing.ts";
16
+ import type { Api, Model } from "@earendil-works/pi-ai";
17
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+
19
+ function makeModel(
20
+ provider: string,
21
+ id: string,
22
+ inputCost = 0,
23
+ outputCost = 0,
24
+ ): Model<Api> {
25
+ return {
26
+ provider,
27
+ id,
28
+ name: id,
29
+ api: "openai-completions" as Api,
30
+ baseUrl: "http://localhost:1234/v1",
31
+ reasoning: false,
32
+ input: ["text"],
33
+ cost: { input: inputCost, output: outputCost, cacheRead: 0, cacheWrite: 0 },
34
+ contextWindow: 128000,
35
+ maxTokens: 4096,
36
+ } as unknown as Model<Api>;
37
+ }
38
+
39
+ function makeCtx(models: Model<Api>[]): ExtensionContext {
40
+ return {
41
+ modelRegistry: {
42
+ find: (provider: string, id: string) =>
43
+ models.find((m) => m.provider === provider && m.id === id),
44
+ getAvailable: () => models,
45
+ },
46
+ } as unknown as ExtensionContext;
47
+ }
48
+
49
+ describe("commands helpers", () => {
50
+ describe("guessTier", () => {
51
+ it("classifies expensive models as frontier", () => {
52
+ const m = makeModel("any", "any-model", 10, 5);
53
+ assert.equal(guessTier(m), "frontier");
54
+ });
55
+
56
+ it("classifies cheap models as economical", () => {
57
+ const m = makeModel("any", "any-model", 0.5, 0.2);
58
+ assert.equal(guessTier(m), "economical");
59
+ });
60
+
61
+ it("classifies free models (cost 0) as economical", () => {
62
+ const m = makeModel("ollama", "local-model", 0, 0);
63
+ assert.equal(guessTier(m), "economical");
64
+ });
65
+
66
+ it("returns undefined for middling cost", () => {
67
+ const m = makeModel("any", "mid-model", 3, 0);
68
+ assert.equal(guessTier(m), undefined);
69
+ });
70
+
71
+ it("handles missing cost fields gracefully", () => {
72
+ const m = makeModel("any", "no-cost", 0, 0);
73
+ (m as any).cost = undefined;
74
+ assert.equal(guessTier(m), "economical");
75
+ });
76
+
77
+ it("classifies high-cost models (>5) as frontier", () => {
78
+ const m = makeModel("any", "expensive", 10, 0);
79
+ assert.equal(guessTier(m), "frontier");
80
+ });
81
+ });
82
+
83
+ describe("selectModel with cheapest strategy", () => {
84
+ it("picks lowest input+output cost", () => {
85
+ const a = makeModel("a", "a", 5, 0);
86
+ const b = makeModel("b", "b", 1, 0);
87
+ const c = makeModel("c", "c", 3, 2);
88
+ const selected = selectModel([a, b, c], "cheapest");
89
+ assert.equal(modelKey(selected), "b/b");
90
+ });
91
+ });
92
+
93
+ describe("findCandidates with multiple patterns", () => {
94
+ it("deduplicates across exact and substring matches", () => {
95
+ const ctx = makeCtx([
96
+ makeModel("anthropic", "claude-opus"),
97
+ makeModel("anthropic", "claude-sonnet"),
98
+ ]);
99
+ const result = findCandidates(ctx, ["anthropic/claude-opus", "anthropic"]);
100
+ assert.equal(result.length, 2);
101
+ });
102
+
103
+ it("returns empty for empty patterns", () => {
104
+ const ctx = makeCtx([]);
105
+ assert.equal(findCandidates(ctx, []).length, 0);
106
+ });
107
+ });
108
+
109
+ describe("getStrategy", () => {
110
+ it("falls back through category → global → default", () => {
111
+ assert.equal(getStrategy({ frontier: "cheapest" }, "first", "frontier"), "cheapest");
112
+ assert.equal(getStrategy({}, "first", "economical"), "first");
113
+ assert.equal(getStrategy(undefined, undefined, "economical"), "first");
114
+ });
115
+ });
116
+ });