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/CONTEXT.md +56 -0
- package/README.md +170 -0
- package/bifrost-routes.json.example +10 -0
- package/bifrost.json +45 -0
- package/cache.ts +171 -0
- package/classification-pipeline.ts +107 -0
- package/classifier.ts +282 -0
- package/commands.ts +565 -0
- package/config.ts +120 -0
- package/debug.ts +169 -0
- package/docs/adr/0001-dual-classifier.md +14 -0
- package/docs/adr/0002-subprocess-classifier.md +15 -0
- package/index.ts +259 -0
- package/package.json +31 -0
- package/probe.ts +144 -0
- package/routing.ts +173 -0
- package/schema.json +189 -0
- package/tests/cache.test.ts +148 -0
- package/tests/classifier.test.ts +48 -0
- package/tests/commands.test.ts +116 -0
- package/tests/integration.test.mjs +157 -0
- package/tests/pipeline.test.ts +269 -0
- package/tests/routing.test.ts +224 -0
- package/tsconfig.json +19 -0
package/commands.ts
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { BifrostConfig } from "./config.js";
|
|
6
|
+
import { DEFAULT_RULES, loadConfig } from "./config.js";
|
|
7
|
+
import type { CacheEntry } from "./cache.js";
|
|
8
|
+
import { cachePath, loadCache, saveCache, DEFAULT_MAX_ENTRIES, DEFAULT_THRESHOLD } from "./cache.js";
|
|
9
|
+
import type { ClassificationPipeline } from "./classification-pipeline.js";
|
|
10
|
+
import { debug, debugMeasure } from "./debug.ts";
|
|
11
|
+
import { runProbe, PROBE_PROMPT_TEXT } from "./probe.js";
|
|
12
|
+
import {
|
|
13
|
+
findCandidates,
|
|
14
|
+
getStrategy,
|
|
15
|
+
guessTier,
|
|
16
|
+
modelKey,
|
|
17
|
+
selectModel,
|
|
18
|
+
type RoutingStrategy,
|
|
19
|
+
} from "./routing.js";
|
|
20
|
+
|
|
21
|
+
// ── Mutable state shared across commands ────────────────────
|
|
22
|
+
|
|
23
|
+
export interface BifrostState {
|
|
24
|
+
config: BifrostConfig;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
classifierEnabled: boolean;
|
|
27
|
+
pinned: boolean;
|
|
28
|
+
cacheEntries: CacheEntry[];
|
|
29
|
+
extensionDir: string;
|
|
30
|
+
getPipeline: (ctx: ExtensionContext) => ClassificationPipeline;
|
|
31
|
+
invalidatePipeline: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function log(
|
|
35
|
+
ctx: ExtensionContext,
|
|
36
|
+
message: string,
|
|
37
|
+
type?: "info" | "warning" | "error",
|
|
38
|
+
) {
|
|
39
|
+
console.error(`[bifrost] ${message}`);
|
|
40
|
+
if (ctx.hasUI) ctx.ui.notify(message, type ?? "info");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function uiBusy(ctx: ExtensionContext, message: string) {
|
|
44
|
+
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
45
|
+
ctx.ui.setWorkingMessage(message);
|
|
46
|
+
ctx.ui.setWorkingVisible(true);
|
|
47
|
+
} else {
|
|
48
|
+
console.error(`[bifrost] ${message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function uiDone(ctx: ExtensionContext) {
|
|
53
|
+
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
54
|
+
ctx.ui.setWorkingMessage(undefined);
|
|
55
|
+
ctx.ui.setWorkingVisible(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function uiOutput(ctx: ExtensionContext, lines: string[]) {
|
|
60
|
+
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
61
|
+
ctx.ui.setWidget("bifrost-output", lines);
|
|
62
|
+
} else {
|
|
63
|
+
for (const line of lines) console.error(`[bifrost] ${line}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Shared tier-resolution + display ───────────────────────
|
|
68
|
+
|
|
69
|
+
function resolveTierDisplay(
|
|
70
|
+
tier: string,
|
|
71
|
+
config: BifrostConfig,
|
|
72
|
+
ctx: ExtensionContext,
|
|
73
|
+
) {
|
|
74
|
+
const pattern = config.models?.[tier] ?? tier;
|
|
75
|
+
const strategy = getStrategy(config.categoryStrategies, config.strategy, tier);
|
|
76
|
+
const candidates = findCandidates(ctx, pattern);
|
|
77
|
+
const selected = selectModel(candidates, strategy);
|
|
78
|
+
|
|
79
|
+
const lines = candidates.map((m) => {
|
|
80
|
+
const marker = m === selected ? "=>" : " ";
|
|
81
|
+
return `${marker} ${modelKey(m)} ($${(m.cost.input + m.cost.output).toFixed(2)}/1M tokens, ctx ${m.contextWindow})`;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return { strategy, candidateLines: lines, selected: selected ? modelKey(selected) : "none" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Command handlers ────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
async function handleInit(
|
|
90
|
+
_args: string,
|
|
91
|
+
ctx: ExtensionContext,
|
|
92
|
+
state: BifrostState,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
// Try to load cached probe results. If stale or missing, run probe inline.
|
|
95
|
+
const probePath = join(process.cwd(), ".pi", "bifrost-probe.json");
|
|
96
|
+
let workingModels: { provider: string; model: string; cost: { input: number; output: number }; duration_ms: number }[] = [];
|
|
97
|
+
let probeLoaded = false;
|
|
98
|
+
let probeAge = "";
|
|
99
|
+
|
|
100
|
+
if (existsSync(probePath)) {
|
|
101
|
+
try {
|
|
102
|
+
const probeData = JSON.parse(readFileSync(probePath, "utf-8"));
|
|
103
|
+
const probeStat = statSync(probePath);
|
|
104
|
+
const ageMs = Date.now() - probeStat.mtimeMs;
|
|
105
|
+
const ageMin = Math.round(ageMs / 60000);
|
|
106
|
+
|
|
107
|
+
if (ageMs < 3600_000) {
|
|
108
|
+
workingModels = probeData
|
|
109
|
+
.filter((r: any) => r.status === "ok")
|
|
110
|
+
.map((r: any) => ({
|
|
111
|
+
provider: r.provider,
|
|
112
|
+
model: r.model,
|
|
113
|
+
cost: { input: r.cost_input ?? 0, output: r.cost_output ?? 0 },
|
|
114
|
+
duration_ms: r.duration_ms ?? 0,
|
|
115
|
+
}));
|
|
116
|
+
probeAge = `${ageMin}m ago`;
|
|
117
|
+
probeLoaded = true;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Corrupt — will re-probe below.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// If no fresh probe data, run probe inline.
|
|
125
|
+
if (!probeLoaded) {
|
|
126
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
127
|
+
const availableCount = available.length;
|
|
128
|
+
log(ctx, `Probing ${availableCount} models to find working ones...`);
|
|
129
|
+
let okCount = 0;
|
|
130
|
+
let errCount = 0;
|
|
131
|
+
const lastModels: string[] = [];
|
|
132
|
+
|
|
133
|
+
uiBusy(ctx, `Probing ${availableCount} models...`);
|
|
134
|
+
const { results } = await runProbe(ctx, (done, total, last) => {
|
|
135
|
+
if (last.status === "ok") okCount++;
|
|
136
|
+
else if (last.status === "error" || last.status === "timeout") errCount++;
|
|
137
|
+
lastModels.push(`${last.provider}/${last.model}: ${last.status} (${last.duration_ms}ms)`);
|
|
138
|
+
if (lastModels.length > 5) lastModels.shift();
|
|
139
|
+
|
|
140
|
+
if (ctx.hasUI) {
|
|
141
|
+
ctx.ui.setWidget("bifrost-probe", [
|
|
142
|
+
`Probing models: ${done}/${total}`,
|
|
143
|
+
` ok: ${okCount} errors: ${errCount}`,
|
|
144
|
+
"",
|
|
145
|
+
...lastModels,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
uiDone(ctx);
|
|
150
|
+
|
|
151
|
+
workingModels = results
|
|
152
|
+
.filter((r) => r.status === "ok")
|
|
153
|
+
.map((r) => ({
|
|
154
|
+
provider: r.provider,
|
|
155
|
+
model: r.model,
|
|
156
|
+
cost: { input: r.cost_input ?? 0, output: r.cost_output ?? 0 },
|
|
157
|
+
duration_ms: r.duration_ms ?? 0,
|
|
158
|
+
}));
|
|
159
|
+
probeLoaded = true;
|
|
160
|
+
probeAge = "just now";
|
|
161
|
+
|
|
162
|
+
const ok = results.filter((r) => r.status === "ok").length;
|
|
163
|
+
log(ctx, `Probe complete: ${ok}/${results.length} models responded.`);
|
|
164
|
+
if (ok === 0) {
|
|
165
|
+
log(ctx, "Zero models responded. Check API keys, network, and credits.", "error");
|
|
166
|
+
log(ctx, "Proceeding with full registry — most models will likely be unreachable.", "warning");
|
|
167
|
+
probeLoaded = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (probeLoaded && workingModels.length > 0) {
|
|
172
|
+
log(ctx, `Using ${workingModels.length} probe-verified models (${probeAge}).`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
176
|
+
const models: Record<string, string[]> = {};
|
|
177
|
+
const uncategorized: string[] = [];
|
|
178
|
+
|
|
179
|
+
for (const m of available) {
|
|
180
|
+
const key = `${m.provider}/${m.id}`;
|
|
181
|
+
|
|
182
|
+
// If probe data is loaded, skip models that failed or timed out.
|
|
183
|
+
if (probeLoaded && workingModels.length > 0) {
|
|
184
|
+
const working = workingModels.find(
|
|
185
|
+
(w) => w.provider === m.provider && w.model === m.id,
|
|
186
|
+
);
|
|
187
|
+
if (!working) continue; // known-broken, silently skip
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const tier = guessTier(m);
|
|
191
|
+
if (tier) {
|
|
192
|
+
models[tier] = models[tier] ?? [];
|
|
193
|
+
models[tier].push(key);
|
|
194
|
+
} else {
|
|
195
|
+
uncategorized.push(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sort each tier by probe response time (fastest first) so "first"
|
|
200
|
+
// strategy picks the fastest model.
|
|
201
|
+
if (probeLoaded) {
|
|
202
|
+
const speedMap = new Map(workingModels.map((w) => [`${w.provider}/${w.model}`, w.duration_ms]));
|
|
203
|
+
for (const tier of Object.keys(models)) {
|
|
204
|
+
models[tier].sort((a, b) => (speedMap.get(a) ?? Infinity) - (speedMap.get(b) ?? Infinity));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Pick a classifier default: fastest cheap working model.
|
|
209
|
+
let classifierModel: string | undefined;
|
|
210
|
+
if (probeLoaded && workingModels.length > 0) {
|
|
211
|
+
const cheapWorking = workingModels
|
|
212
|
+
.filter((w) => (w.cost.input + w.cost.output) < 2)
|
|
213
|
+
.sort((a, b) => a.duration_ms - b.duration_ms);
|
|
214
|
+
if (cheapWorking.length > 0) {
|
|
215
|
+
classifierModel = `${cheapWorking[0].provider}/${cheapWorking[0].model}`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!classifierModel) {
|
|
219
|
+
// Fallback: any working model, or a sensible default.
|
|
220
|
+
if (workingModels.length > 0) {
|
|
221
|
+
classifierModel = `${workingModels[0].provider}/${workingModels[0].model}`;
|
|
222
|
+
} else {
|
|
223
|
+
classifierModel = "opencode/mimo-v2.5-free";
|
|
224
|
+
log(ctx, "No working models found for classifier. Using default — it may not work.", "warning");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const proposal = {
|
|
229
|
+
$schema: `${state.extensionDir.replace(/\/$/, "")}/schema.json`,
|
|
230
|
+
enabled: true,
|
|
231
|
+
default: "economical",
|
|
232
|
+
strategy: "first" as RoutingStrategy,
|
|
233
|
+
categoryStrategies: { economical: "cheapest" as RoutingStrategy },
|
|
234
|
+
classifier: {
|
|
235
|
+
enabled: true,
|
|
236
|
+
model: classifierModel,
|
|
237
|
+
method: "auto" as const,
|
|
238
|
+
},
|
|
239
|
+
models,
|
|
240
|
+
rules: DEFAULT_RULES,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const totalAssigned = Object.values(models).reduce((s, v) => s + v.length, 0);
|
|
244
|
+
uiOutput(ctx, [
|
|
245
|
+
"--- init ---",
|
|
246
|
+
`source: ${probeLoaded ? `probe (${workingModels.length} working)` : `registry (${available.length} listed)`}`,
|
|
247
|
+
`assigned: ${totalAssigned} models`,
|
|
248
|
+
`classifier: ${classifierModel}`,
|
|
249
|
+
`uncategorized: ${uncategorized.length}`,
|
|
250
|
+
"proposed config:",
|
|
251
|
+
JSON.stringify(proposal, null, 2),
|
|
252
|
+
"----------------",
|
|
253
|
+
probeLoaded ? "" : "⚠ Run /bifrost probe first to filter unreachable models.",
|
|
254
|
+
"Assign uncategorized models manually in the generated config.",
|
|
255
|
+
].filter(Boolean));
|
|
256
|
+
|
|
257
|
+
if (uncategorized.length > 0) {
|
|
258
|
+
log(ctx, `${uncategorized.length} model(s) uncategorized — edit .pi/bifrost.json to assign them.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!ctx.hasUI) {
|
|
262
|
+
log(ctx, "run in TUI or use --write (not implemented) to persist", "warning");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const ok = await ctx.ui.confirm(
|
|
267
|
+
"Write config?",
|
|
268
|
+
"Write proposed config to .pi/bifrost.json?",
|
|
269
|
+
);
|
|
270
|
+
if (!ok) {
|
|
271
|
+
log(ctx, "config not written");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const dir = join(process.cwd(), CONFIG_DIR_NAME);
|
|
276
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
277
|
+
writeFileSync(join(dir, "bifrost.json"), JSON.stringify(proposal, null, 2));
|
|
278
|
+
|
|
279
|
+
// Auto-reload so the extension picks up the new config immediately.
|
|
280
|
+
state.config = loadConfig(process.cwd(), state.extensionDir);
|
|
281
|
+
state.enabled = state.config.enabled ?? true;
|
|
282
|
+
state.classifierEnabled = state.config.classifier?.enabled ?? true;
|
|
283
|
+
state.invalidatePipeline();
|
|
284
|
+
|
|
285
|
+
log(ctx, "wrote .pi/bifrost.json and reloaded config");
|
|
286
|
+
log(ctx, `Bifrost active with ${Object.keys(state.config.models ?? {}).length} tier(s). Try a prompt.`);
|
|
287
|
+
|
|
288
|
+
// Clear the init widget so it doesn't persist in the TUI.
|
|
289
|
+
if (ctx.hasUI) ctx.ui.setWidget("bifrost-output", []);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function handleBenchmark(
|
|
293
|
+
args: string,
|
|
294
|
+
ctx: ExtensionContext,
|
|
295
|
+
state: BifrostState,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
const prompt =
|
|
298
|
+
args.slice("benchmark".length).trim() ||
|
|
299
|
+
"Write a short Python function to reverse a string and explain it briefly.";
|
|
300
|
+
const categories = Object.keys(state.config.models ?? {});
|
|
301
|
+
|
|
302
|
+
if (categories.length === 0) {
|
|
303
|
+
log(ctx, "no categories configured; run /bifrost init first", "warning");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
uiBusy(ctx, "Classifying benchmark prompt...");
|
|
308
|
+
const classification = await state.getPipeline(ctx).classify(prompt);
|
|
309
|
+
uiDone(ctx);
|
|
310
|
+
const tier = classification.kind !== "unclassified" ? classification.tier : undefined;
|
|
311
|
+
const source = classification.kind === "classified" ? classification.source : "fallback";
|
|
312
|
+
|
|
313
|
+
const lines = [
|
|
314
|
+
"--- benchmark ---",
|
|
315
|
+
`prompt: ${prompt}`,
|
|
316
|
+
`tier: ${tier ?? "none"}`,
|
|
317
|
+
`source: ${source}`,
|
|
318
|
+
"per-tier selection:",
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
for (const tierName of categories) {
|
|
322
|
+
const display = resolveTierDisplay(tierName, state.config, ctx);
|
|
323
|
+
lines.push(` ${tierName} (${display.strategy}):`);
|
|
324
|
+
lines.push(...display.candidateLines);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
lines.push("-----------------");
|
|
328
|
+
uiOutput(ctx, lines);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handlePreview(
|
|
332
|
+
args: string,
|
|
333
|
+
ctx: ExtensionContext,
|
|
334
|
+
state: BifrostState,
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
const prompt = args.slice("preview".length).trim();
|
|
337
|
+
if (!prompt) {
|
|
338
|
+
log(ctx, "usage: /bifrost preview <prompt>", "warning");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
uiBusy(ctx, "Classifying preview prompt...");
|
|
343
|
+
const classification = await state.getPipeline(ctx).classify(prompt);
|
|
344
|
+
uiDone(ctx);
|
|
345
|
+
if (classification.kind === "unclassified") {
|
|
346
|
+
log(ctx, "no tier matched", "warning");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const tier = classification.tier;
|
|
350
|
+
const source = classification.kind === "classified" ? classification.source : "fallback";
|
|
351
|
+
const display = resolveTierDisplay(tier, state.config, ctx);
|
|
352
|
+
|
|
353
|
+
uiOutput(ctx, [
|
|
354
|
+
"--- preview ---",
|
|
355
|
+
`prompt: ${prompt}`,
|
|
356
|
+
`source: ${source}`,
|
|
357
|
+
`tier: ${tier}`,
|
|
358
|
+
`strategy: ${display.strategy}`,
|
|
359
|
+
`candidates:`,
|
|
360
|
+
...display.candidateLines,
|
|
361
|
+
`selected: ${display.selected}`,
|
|
362
|
+
"---------------",
|
|
363
|
+
]);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Command type ────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
type CommandFn = (args: string, ctx: ExtensionContext) => void | Promise<void>;
|
|
369
|
+
|
|
370
|
+
interface CommandEntry {
|
|
371
|
+
readonly match: (sub: string) => boolean;
|
|
372
|
+
readonly handler: CommandFn;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function exact(word: string, handler: CommandFn): CommandEntry {
|
|
376
|
+
return { match: (sub) => sub === word, handler };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function prefix(word: string, handler: CommandFn): CommandEntry {
|
|
380
|
+
return { match: (sub) => sub.startsWith(word), handler };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Route table ─────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
export function createCommandRouter(
|
|
386
|
+
state: BifrostState,
|
|
387
|
+
): (args: string, ctx: ExtensionContext) => Promise<void> {
|
|
388
|
+
const routes: CommandEntry[] = [
|
|
389
|
+
exact("on", (_, ctx) => {
|
|
390
|
+
state.enabled = true;
|
|
391
|
+
log(ctx, "Bifrost enabled");
|
|
392
|
+
}),
|
|
393
|
+
exact("off", (_, ctx) => {
|
|
394
|
+
state.enabled = false;
|
|
395
|
+
log(ctx, "Bifrost disabled");
|
|
396
|
+
}),
|
|
397
|
+
exact("pin", (_, ctx) => {
|
|
398
|
+
state.pinned = true;
|
|
399
|
+
log(ctx, "Bifrost pinned");
|
|
400
|
+
}),
|
|
401
|
+
exact("unpin", (_, ctx) => {
|
|
402
|
+
state.pinned = false;
|
|
403
|
+
log(ctx, "Bifrost unpinned");
|
|
404
|
+
}),
|
|
405
|
+
exact("reload", (_, ctx) => {
|
|
406
|
+
const done = debugMeasure("command", "reload");
|
|
407
|
+
state.config = loadConfig(process.cwd(), state.extensionDir);
|
|
408
|
+
state.enabled = state.config.enabled ?? true;
|
|
409
|
+
state.classifierEnabled = state.config.classifier?.enabled ?? true;
|
|
410
|
+
state.pinned = false;
|
|
411
|
+
state.cacheEntries = loadCache(cachePath(process.cwd(), state.config.cache?.path));
|
|
412
|
+
state.invalidatePipeline();
|
|
413
|
+
done();
|
|
414
|
+
debug("command", "reloaded", {
|
|
415
|
+
enabled: state.enabled,
|
|
416
|
+
classifierEnabled: state.classifierEnabled,
|
|
417
|
+
tiers: Object.keys(state.config.models ?? {}).join(","),
|
|
418
|
+
});
|
|
419
|
+
log(ctx, "Bifrost config reloaded");
|
|
420
|
+
}),
|
|
421
|
+
|
|
422
|
+
// Providers
|
|
423
|
+
exact("providers", (_, ctx) => {
|
|
424
|
+
uiBusy(ctx, "Loading providers...");
|
|
425
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
426
|
+
const counts = new Map<string, number>();
|
|
427
|
+
for (const m of available) {
|
|
428
|
+
counts.set(m.provider, (counts.get(m.provider) ?? 0) + 1);
|
|
429
|
+
}
|
|
430
|
+
uiDone(ctx);
|
|
431
|
+
uiOutput(ctx, [
|
|
432
|
+
"available providers:",
|
|
433
|
+
...Array.from(counts.entries()).map(
|
|
434
|
+
([provider, count]) => ` ${provider}: ${count} model(s)`,
|
|
435
|
+
),
|
|
436
|
+
]);
|
|
437
|
+
}),
|
|
438
|
+
|
|
439
|
+
// Probe — test every model with a tiny prompt
|
|
440
|
+
exact("probe", async (_, ctx) => {
|
|
441
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
442
|
+
if (available.length === 0) {
|
|
443
|
+
log(ctx, "No models available in registry.", "warning");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
uiBusy(ctx, `Probing ${available.length} models...`);
|
|
447
|
+
log(ctx, `Probing ${available.length} model(s) with "${PROBE_PROMPT_TEXT}"...`);
|
|
448
|
+
|
|
449
|
+
const { results, path } = await runProbe(ctx);
|
|
450
|
+
uiDone(ctx);
|
|
451
|
+
|
|
452
|
+
const ok = results.filter((r) => r.status === "ok");
|
|
453
|
+
const errs = results.filter((r) => r.status === "error");
|
|
454
|
+
const timeouts = results.filter((r) => r.status === "timeout");
|
|
455
|
+
const skipped = results.filter((r) => r.status === "skipped");
|
|
456
|
+
|
|
457
|
+
const lines = [
|
|
458
|
+
`--- probe results (${results.length} models) ---`,
|
|
459
|
+
` ok: ${ok.length}`,
|
|
460
|
+
` error: ${errs.length}`,
|
|
461
|
+
` timeout: ${timeouts.length}`,
|
|
462
|
+
` skipped: ${skipped.length}`,
|
|
463
|
+
"",
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
if (errs.length > 0) {
|
|
467
|
+
lines.push("errors:");
|
|
468
|
+
for (const e of errs.slice(0, 10)) {
|
|
469
|
+
lines.push(` ${e.provider}/${e.model} — ${e.error}`);
|
|
470
|
+
}
|
|
471
|
+
if (errs.length > 10) lines.push(` ... and ${errs.length - 10} more`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (timeouts.length > 0) {
|
|
475
|
+
lines.push("timeouts:");
|
|
476
|
+
for (const t of timeouts) {
|
|
477
|
+
lines.push(` ${t.provider}/${t.model}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
lines.push("", `full results → ${path}`);
|
|
482
|
+
uiOutput(ctx, lines);
|
|
483
|
+
|
|
484
|
+
if (ok.length < results.length) {
|
|
485
|
+
log(
|
|
486
|
+
ctx,
|
|
487
|
+
`${ok.length}/${results.length} models responded. Check ${path} for details.`,
|
|
488
|
+
"warning",
|
|
489
|
+
);
|
|
490
|
+
} else if (ok.length > 0) {
|
|
491
|
+
log(ctx, `All ${ok.length} models responded successfully.`);
|
|
492
|
+
}
|
|
493
|
+
}),
|
|
494
|
+
|
|
495
|
+
// Init
|
|
496
|
+
exact("init", (args, ctx) => handleInit(args, ctx, state)),
|
|
497
|
+
|
|
498
|
+
// Benchmark
|
|
499
|
+
prefix("benchmark", (args, ctx) => handleBenchmark(args, ctx, state)),
|
|
500
|
+
|
|
501
|
+
// Cache
|
|
502
|
+
exact("cache stats", (_, ctx) => {
|
|
503
|
+
const path = cachePath(process.cwd(), state.config.cache?.path);
|
|
504
|
+
const entries = loadCache(path);
|
|
505
|
+
log(
|
|
506
|
+
ctx,
|
|
507
|
+
`cache: ${entries.length} entries (cap ${state.config.cache?.maxEntries ?? DEFAULT_MAX_ENTRIES}, threshold ${state.config.cache?.threshold ?? DEFAULT_THRESHOLD})`,
|
|
508
|
+
);
|
|
509
|
+
}),
|
|
510
|
+
exact("cache clear", (_, ctx) => {
|
|
511
|
+
const path = cachePath(process.cwd(), state.config.cache?.path);
|
|
512
|
+
saveCache(path, []);
|
|
513
|
+
state.cacheEntries = [];
|
|
514
|
+
state.invalidatePipeline();
|
|
515
|
+
log(ctx, "cache cleared");
|
|
516
|
+
}),
|
|
517
|
+
|
|
518
|
+
// Classifier
|
|
519
|
+
exact("classifier on", (_, ctx) => {
|
|
520
|
+
state.classifierEnabled = true;
|
|
521
|
+
state.invalidatePipeline();
|
|
522
|
+
debug("command", "classifier_toggle", { enabled: true });
|
|
523
|
+
log(ctx, "LLM classifier enabled");
|
|
524
|
+
}),
|
|
525
|
+
exact("classifier off", (_, ctx) => {
|
|
526
|
+
state.classifierEnabled = false;
|
|
527
|
+
state.invalidatePipeline();
|
|
528
|
+
debug("command", "classifier_toggle", { enabled: false });
|
|
529
|
+
log(ctx, "LLM classifier disabled; regex fallback active");
|
|
530
|
+
}),
|
|
531
|
+
exact("classifier status", (_, ctx) => {
|
|
532
|
+
const rawModel = state.config.classifier?.model;
|
|
533
|
+
const modelId = Array.isArray(rawModel)
|
|
534
|
+
? rawModel.join(", ")
|
|
535
|
+
: (rawModel ?? "none");
|
|
536
|
+
log(
|
|
537
|
+
ctx,
|
|
538
|
+
`classifier: enabled=${state.classifierEnabled} model=${modelId} endpoint=${state.config.classifier?.endpoint ?? "registry"} method=${state.config.classifier?.method ?? "auto"}`,
|
|
539
|
+
);
|
|
540
|
+
}),
|
|
541
|
+
|
|
542
|
+
// Preview
|
|
543
|
+
prefix("preview", (args, ctx) => handlePreview(args, ctx, state)),
|
|
544
|
+
];
|
|
545
|
+
|
|
546
|
+
return async (args: string, ctx: ExtensionContext) => {
|
|
547
|
+
const trimmed = args.trim();
|
|
548
|
+
const sub = trimmed.toLowerCase();
|
|
549
|
+
|
|
550
|
+
for (const route of routes) {
|
|
551
|
+
if (route.match(sub)) {
|
|
552
|
+
debug("command", "dispatch", { command: sub });
|
|
553
|
+
await route.handler(trimmed, ctx);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Fallback: status
|
|
559
|
+
debug("command", "status");
|
|
560
|
+
log(
|
|
561
|
+
ctx,
|
|
562
|
+
`Bifrost: enabled=${state.enabled} pinned=${state.pinned} current=${modelKey(ctx.model)}`,
|
|
563
|
+
);
|
|
564
|
+
};
|
|
565
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { RoutingStrategy, RouteRule } from "./routing.js";
|
|
5
|
+
import type { CacheOptions } from "./cache.js";
|
|
6
|
+
import type { DebugConfig } from "./debug.js";
|
|
7
|
+
|
|
8
|
+
type ClassifierMethod = "direct" | "subprocess" | "auto";
|
|
9
|
+
|
|
10
|
+
export interface ClassifierConfig {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
model?: string | string[];
|
|
13
|
+
endpoint?: string;
|
|
14
|
+
method?: ClassifierMethod;
|
|
15
|
+
systemPrompt?: string;
|
|
16
|
+
maxTokens?: number;
|
|
17
|
+
temperature?: number;
|
|
18
|
+
fallbackToRegex?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BifrostConfig {
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
default?: string;
|
|
24
|
+
strategy?: RoutingStrategy;
|
|
25
|
+
categoryStrategies?: Record<string, RoutingStrategy>;
|
|
26
|
+
models?: Record<string, string | string[]>;
|
|
27
|
+
rules?: RouteRule[];
|
|
28
|
+
classifier?: ClassifierConfig;
|
|
29
|
+
cache?: CacheOptions;
|
|
30
|
+
debug?: DebugConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_RULES: RouteRule[] = [
|
|
34
|
+
{
|
|
35
|
+
pattern:
|
|
36
|
+
"\\b(plan|design|architect|refactor|debug|fix|implement|complex|performance|memory leak|race condition)\\b",
|
|
37
|
+
model: "frontier",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pattern:
|
|
41
|
+
"\\b(explain|summarize|define|what is|how to|format|lint|convert|hello|idea)\\b",
|
|
42
|
+
model: "economical",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function readJson<T>(path: string): T | undefined {
|
|
47
|
+
if (!existsSync(path)) return undefined;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[bifrost] failed to parse ${path}: ${err}`);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mergeConfig<T extends Record<string, unknown>>(
|
|
57
|
+
base: T,
|
|
58
|
+
override: T,
|
|
59
|
+
): T {
|
|
60
|
+
const merged: T = { ...base };
|
|
61
|
+
for (const key of Object.keys(override) as Array<keyof T>) {
|
|
62
|
+
const baseVal = base[key];
|
|
63
|
+
const overrideVal = override[key];
|
|
64
|
+
if (overrideVal === undefined) continue;
|
|
65
|
+
if (
|
|
66
|
+
typeof baseVal === "object" &&
|
|
67
|
+
baseVal !== null &&
|
|
68
|
+
!Array.isArray(baseVal) &&
|
|
69
|
+
typeof overrideVal === "object" &&
|
|
70
|
+
overrideVal !== null &&
|
|
71
|
+
!Array.isArray(overrideVal)
|
|
72
|
+
) {
|
|
73
|
+
merged[key] = { ...baseVal, ...overrideVal } as T[typeof key];
|
|
74
|
+
} else {
|
|
75
|
+
merged[key] = overrideVal as T[typeof key];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function loadConfig(
|
|
82
|
+
cwd: string,
|
|
83
|
+
extensionDir: string,
|
|
84
|
+
): BifrostConfig {
|
|
85
|
+
const base: BifrostConfig = {
|
|
86
|
+
enabled: true,
|
|
87
|
+
default: "economical",
|
|
88
|
+
strategy: "first",
|
|
89
|
+
categoryStrategies: { economical: "cheapest" },
|
|
90
|
+
models: {},
|
|
91
|
+
rules: DEFAULT_RULES,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const configs = [
|
|
95
|
+
readJson<BifrostConfig>(join(extensionDir, "bifrost.json")),
|
|
96
|
+
readJson<BifrostConfig>(join(getAgentDir(), "bifrost.json")),
|
|
97
|
+
readJson<BifrostConfig>(join(cwd, CONFIG_DIR_NAME, "bifrost.json")),
|
|
98
|
+
readJson<BifrostConfig>(join(cwd, "bifrost.json")),
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
let merged = base as unknown as Record<string, unknown>;
|
|
102
|
+
for (const cfg of configs) {
|
|
103
|
+
if (cfg) merged = mergeConfig(merged, cfg as unknown as Record<string, unknown>);
|
|
104
|
+
}
|
|
105
|
+
return merged as unknown as BifrostConfig;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function loadRules(cwd: string, config: BifrostConfig): RouteRule[] {
|
|
109
|
+
const routeFiles = [
|
|
110
|
+
join(cwd, CONFIG_DIR_NAME, "bifrost-routes.json"),
|
|
111
|
+
join(cwd, "bifrost-routes.json"),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
for (const p of routeFiles) {
|
|
115
|
+
const rules = readJson<RouteRule[]>(p);
|
|
116
|
+
if (rules) return rules;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return config.rules?.length ? config.rules : DEFAULT_RULES;
|
|
120
|
+
}
|