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 ADDED
@@ -0,0 +1,56 @@
1
+ # pi-bifrost
2
+
3
+ Query-aware model router extension for the pi coding agent. Classifies each user prompt, selects the best model from configured tiers, and switches pi's active model transparently. No native dependencies — uses pi's built-in model registry.
4
+
5
+ ## Language
6
+
7
+ **Tier**:
8
+ A quality/cost class of models (e.g. `frontier`, `economical`). Each tier maps to one or more model patterns. The classifier or regex rules produce a tier name; model selection then picks a candidate from that tier.
9
+ _Avoid_: category (the code uses both; prefer tier)
10
+
11
+ **Route rule**:
12
+ A regex pattern paired with a target tier. Rules are matched case-insensitively in order. First match wins. Example: `pattern: "\\b(debug|fix)\\b"` → `model: "frontier"`.
13
+ _Avoid_: routing rule, classification rule
14
+
15
+ **Classifier**:
16
+ The LLM-based tier selector. Invoked before regex rules. Uses a cheap model to read the prompt and respond with a tier name. Two invocation methods: direct HTTP (fast, OpenAI-compatible) or subprocess (spawns a fresh pi CLI; slower but covers any provider).
17
+
18
+ **Strategy**:
19
+ How to pick among candidates within a tier. `first` = first available candidate. `cheapest` = lowest combined input+output cost per 1M tokens. Set globally or per-tier via `categoryStrategies`.
20
+
21
+ **Candidate**:
22
+ A model that matches a tier's pattern list. Resolved from pi's model registry. A tier may list multiple patterns; each pattern may match zero or more models.
23
+
24
+ **Model pattern**:
25
+ A string that resolves to models in pi's registry. Two forms: exact `provider/id` (e.g. `anthropic/claude-opus-4-5`) or bare substring (e.g. `qwen2.5-coder` matches any provider containing that string).
26
+
27
+ **Pin**:
28
+ Freeze the current model and stop automatic routing. Triggered automatically when the user runs `/model`, or manually via `/bifrost pin`. Reversed by `/bifrost unpin`.
29
+
30
+ **Config layer**:
31
+ One of four config sources merged in order: extension default → global (`~/.pi/agent/`) → project-local (`.pi/`) → project root. Later layers override earlier ones. The rules file (`bifrost-routes.json`) is loaded separately and overrides inline `rules` in `bifrost.json`.
32
+
33
+ **Fuzzy cache**:
34
+ Persisted classification cache using Jaccard token-set similarity. Stores `(normalizedPrompt, tier)` pairs in `.pi/bifrost-cache.jsonl`. On cache hit, skips the classifier entirely. Eviction is LRU by last-use timestamp.
35
+
36
+ ## Example dialogue
37
+
38
+ **Dev:** "User types 'help me debug this race condition in my Go backend.' What happens?"
39
+
40
+ **Expert:** "Prompt enters the pipeline. First, fuzzy cache check — has Bifrost seen a similar prompt? If yes, reuse the cached tier, skip the classifier. If no, the LLM classifier reads the prompt and ideally returns `frontier` — 'debug' and 'race condition' are strong frontier signals. If the classifier is down, regex rules catch it — both 'debug' and 'race condition' hit the frontier rule. Once we have the tier, we look up the frontier model patterns, resolve candidates from the registry, apply the strategy — say `cheapest` — and switch pi's model."
41
+
42
+ **Dev:** "And if nothing matches?"
43
+
44
+ **Expert:** "Falls through to the default tier, configured as `economical`. Safer to waste a cheap model on a hard prompt than burn frontier credits on 'hello world'."
45
+
46
+ **Dev:** "What happens when the user manually switches models with /model?"
47
+
48
+ **Expert:** "Bifrost detects the `model_select` event, pins itself, and logs a warning. It stops routing until the user runs `/bifrost unpin`. The assumption: if you manually picked a model, you had a reason."
49
+
50
+ **Dev:** "So the classifier costs tokens on every prompt?"
51
+
52
+ **Expert:** "Only on cache misses. The classifier model is configured to be cheap — typically a local Ollama model or a free-tier cloud model. Max 20 output tokens. And the fuzzy cache means repeat or near-repeat prompts skip it entirely. With threshold 0.85, 'debug the race condition' and 'help me debug a race condition' share a cache entry."
53
+
54
+ ## Flagged ambiguities
55
+
56
+ - **tier vs category**: The codebase uses both interchangeably. The schema.json and `guessTier()` use "tier"; `classifyPrompt()` and cache entries use "category." The config key `categoryStrategies` retains the old name for backward compatibility. Prefer "tier" in new code and docs.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # Bifrost
2
+
3
+ Automatic model routing for [pi](https://pi.dev). Reads your prompt, picks the right model, switches automatically. No thinking required.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install ./pi-bifrost
9
+ ```
10
+
11
+ Or test first:
12
+
13
+ ```bash
14
+ pi -e ./pi-bifrost/index.ts
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ Run this once:
20
+
21
+ ```
22
+ /bifrost init
23
+ ```
24
+
25
+ It probes every model you have access to, finds the ones that actually work, and writes a config. Say yes when asked.
26
+
27
+ Done. Bifrost is now routing your prompts.
28
+
29
+ ## What it does
30
+
31
+ You type a prompt. Bifrost classifies it as `frontier` (hard) or `economical` (easy), picks the best available model for that tier, and switches pi's model before the prompt is sent. You just type — the model changes behind the scenes.
32
+
33
+ If you manually switch models with `/model`, Bifrost pins itself and stops routing. `/bifrost unpin` to resume.
34
+
35
+ ## Commands
36
+
37
+ | Command | What it does |
38
+ |---------|-------------|
39
+ | `/bifrost` | Show current status |
40
+ | `/bifrost init` | Probe models and generate config |
41
+ | `/bifrost probe` | Test which models actually respond |
42
+ | `/bifrost preview <prompt>` | See what would happen without sending |
43
+ | `/bifrost on` / `off` | Enable / disable routing |
44
+ | `/bifrost pin` / `unpin` | Lock current model / resume routing |
45
+ | `/bifrost reload` | Reload config after editing |
46
+ | `/bifrost cache stats` | Show classification cache |
47
+ | `/bifrost cache clear` | Clear classification cache |
48
+ | `/bifrost classifier status` | Show classifier state |
49
+
50
+ ## Config
51
+
52
+ Everything lives in `.pi/bifrost.json`. Editor autocomplete works in VS Code, Zed, Cursor.
53
+
54
+ ### Tiers and models
55
+
56
+ ```json
57
+ {
58
+ "models": {
59
+ "economical": [
60
+ "opencode/deepseek-v4-flash-free",
61
+ "lmstudio/openai/gpt-oss-20b"
62
+ ],
63
+ "frontier": [
64
+ "opencode-go/glm-5.1",
65
+ "opencode-go/grok-4.5"
66
+ ]
67
+ }
68
+ }
69
+ ```
70
+
71
+ Each tier has a list of model patterns. `provider/id` for exact, or just `qwen` for substring match.
72
+
73
+ ### Strategies
74
+
75
+ How Bifrost picks from the list. Set globally or per-tier:
76
+
77
+ | Strategy | Picks |
78
+ |----------|-------|
79
+ | `cheapest` | Lowest cost (input + output) |
80
+ | `cheapest_input` | Lowest input cost |
81
+ | `cheapest_output` | Lowest output cost |
82
+ | `largest_context` | Biggest context window |
83
+ | `random` | Random pick |
84
+ | `first` / `fastest` | First in list (init sorts by speed) |
85
+
86
+ ```json
87
+ {
88
+ "strategy": "first",
89
+ "categoryStrategies": {
90
+ "economical": "cheapest"
91
+ }
92
+ }
93
+ ```
94
+
95
+ ### Routing rules
96
+
97
+ Regex patterns that map prompts to tiers. First match wins. Case insensitive.
98
+
99
+ ```json
100
+ {
101
+ "rules": [
102
+ {
103
+ "pattern": "\\b(debug|fix|implement|refactor|architect)\\b",
104
+ "model": "frontier"
105
+ },
106
+ {
107
+ "pattern": "\\b(explain|summarize|format|hello)\\b",
108
+ "model": "economical"
109
+ }
110
+ ]
111
+ }
112
+ ```
113
+
114
+ Or use a separate `.pi/bifrost-routes.json` file — it overrides inline rules.
115
+
116
+ ### Classifier
117
+
118
+ An LLM that reads your prompt and picks a tier. More accurate than regex, costs a few tokens. Uses a cheap model — you configure which one.
119
+
120
+ ```json
121
+ {
122
+ "classifier": {
123
+ "enabled": true,
124
+ "model": "opencode/mimo-v2.5-free"
125
+ }
126
+ }
127
+ ```
128
+
129
+ If the classifier fails or is disabled, regex rules take over. Both paths cache results so repeat prompts skip the check entirely.
130
+
131
+ ### Debug logging
132
+
133
+ ```json
134
+ {
135
+ "debug": { "enabled": true }
136
+ }
137
+ ```
138
+
139
+ Writes `.pi/bifrost-debug.jsonl` — one JSON line per event. Timings, tiers, decisions. Useful for understanding what Bifrost is doing.
140
+
141
+ ### Full config reference
142
+
143
+ ```json
144
+ {
145
+ "$schema": "../pi-bifrost/schema.json",
146
+ "enabled": true,
147
+ "default": "economical",
148
+ "strategy": "first",
149
+ "categoryStrategies": { "economical": "cheapest" },
150
+ "models": { ... },
151
+ "rules": [ ... ],
152
+ "classifier": {
153
+ "enabled": true,
154
+ "model": "...",
155
+ "method": "auto",
156
+ "maxTokens": 20,
157
+ "temperature": 0,
158
+ "fallbackToRegex": true,
159
+ "systemPrompt": "..."
160
+ },
161
+ "cache": {
162
+ "enabled": true,
163
+ "maxEntries": 500,
164
+ "threshold": 0.85
165
+ },
166
+ "debug": { "enabled": false }
167
+ }
168
+ ```
169
+
170
+ Every field is optional. Config merges from: extension default → global (`~/.pi/agent/bifrost.json`) → project (`.pi/bifrost.json`).
@@ -0,0 +1,10 @@
1
+ [
2
+ {
3
+ "pattern": "\\b(plan|design|architect|refactor|debug|fix|implement|complex|performance|memory leak|race condition)\\b",
4
+ "model": "frontier"
5
+ },
6
+ {
7
+ "pattern": "\\b(explain|summarize|define|what is|how to|format|lint|convert|hello|idea)\\b",
8
+ "model": "economical"
9
+ }
10
+ ]
package/bifrost.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "enabled": true,
4
+ "default": "economical",
5
+ "strategy": "first",
6
+ "categoryStrategies": {
7
+ "economical": "cheapest"
8
+ },
9
+ "classifier": {
10
+ "enabled": true,
11
+ "model": "opencode/deepseek-v4-flash-free",
12
+ "method": "auto",
13
+ "maxTokens": 20,
14
+ "temperature": 0,
15
+ "fallbackToRegex": true
16
+ },
17
+ "cache": {
18
+ "enabled": true,
19
+ "maxEntries": 500,
20
+ "threshold": 0.85
21
+ },
22
+ "debug": {
23
+ "enabled": false
24
+ },
25
+ "models": {
26
+ "economical": [
27
+ "ollama/qwen2.5-coder:latest",
28
+ "lmstudio/openai/gpt-oss-20b"
29
+ ],
30
+ "frontier": [
31
+ "openai-codex/gpt-5.4",
32
+ "opencode-go/kimi-k3"
33
+ ]
34
+ },
35
+ "rules": [
36
+ {
37
+ "pattern": "\\b(plan|design|architect|refactor|debug|fix|implement|complex|performance|memory leak|race condition)\\b",
38
+ "model": "frontier"
39
+ },
40
+ {
41
+ "pattern": "\\b(explain|summarize|define|what is|how to|format|lint|convert|hello|idea)\\b",
42
+ "model": "economical"
43
+ }
44
+ ]
45
+ }
package/cache.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export interface CacheEntry {
5
+ normalized: string;
6
+ category: string;
7
+ lastUsed: number;
8
+ hits: number;
9
+ /** Monotonic sequence number for stable eviction ordering. */
10
+ seq?: number;
11
+ }
12
+
13
+ export interface CacheOptions {
14
+ enabled?: boolean;
15
+ maxEntries?: number;
16
+ threshold?: number;
17
+ path?: string;
18
+ }
19
+
20
+ export const DEFAULT_MAX_ENTRIES = 500;
21
+ export const DEFAULT_THRESHOLD = 0.85;
22
+
23
+ let nextSeq = 0;
24
+
25
+ export function normalize(text: string): string {
26
+ return text
27
+ .toLowerCase()
28
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
29
+ .split(" ")
30
+ .filter(Boolean)
31
+ .sort()
32
+ .join(" ");
33
+ }
34
+
35
+ function tokenSet(text: string): Set<string> {
36
+ return new Set(normalize(text).split(" "));
37
+ }
38
+
39
+ export function loadCache(path: string): CacheEntry[] {
40
+ if (!existsSync(path)) return [];
41
+ try {
42
+ const text = readFileSync(path, "utf-8");
43
+ const entries = text
44
+ .split("\n")
45
+ .filter(Boolean)
46
+ .map((line: string) => {
47
+ try {
48
+ return JSON.parse(line) as CacheEntry;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ })
53
+ .filter((e): e is CacheEntry => e !== undefined);
54
+
55
+ // Seed seq counter from loaded entries to avoid collision on restart.
56
+ const maxSeq = entries.reduce((max: number, e: CacheEntry) => Math.max(max, e.seq ?? 0), 0);
57
+ if (maxSeq >= nextSeq) nextSeq = maxSeq + 1;
58
+
59
+ return entries;
60
+ } catch (err) {
61
+ console.error(`[bifrost] failed to load cache: ${err}`);
62
+ return [];
63
+ }
64
+ }
65
+
66
+ export function saveCache(path: string, entries: CacheEntry[]) {
67
+ try {
68
+ const dir = dirname(path);
69
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
70
+ const lines = entries.map((e) => JSON.stringify(e)).join("\n");
71
+ writeFileSync(path, lines ? lines + "\n" : "", "utf-8");
72
+ } catch (err) {
73
+ console.error(`[bifrost] failed to save cache: ${err}`);
74
+ }
75
+ }
76
+
77
+ function evictIfNeeded(entries: CacheEntry[], maxEntries: number): CacheEntry[] {
78
+ if (entries.length <= maxEntries) return entries;
79
+ // Sort by lastUsed descending; tie-break by seq (newer entries have higher seq).
80
+ return [...entries]
81
+ .sort((a, b) => b.lastUsed - a.lastUsed || (b.seq ?? 0) - (a.seq ?? 0))
82
+ .slice(0, maxEntries);
83
+ }
84
+
85
+ /** Pure query — finds matching cache entry without mutation. */
86
+ export function lookupCache(
87
+ entries: CacheEntry[],
88
+ prompt: string,
89
+ threshold: number,
90
+ ): CacheEntry | undefined {
91
+ const normalized = normalize(prompt);
92
+ const promptTokens = tokenSet(normalized);
93
+ let best: { entry: CacheEntry; score: number } | undefined;
94
+
95
+ for (const entry of entries) {
96
+ if (entry.normalized === normalized) return entry;
97
+
98
+ const entryTokens = tokenSet(entry.normalized);
99
+ if (promptTokens.size === 0 || entryTokens.size === 0) continue;
100
+
101
+ let intersection = 0;
102
+ for (const token of promptTokens) {
103
+ if (entryTokens.has(token)) intersection++;
104
+ }
105
+ const union = promptTokens.size + entryTokens.size - intersection;
106
+ const score = union === 0 ? 0 : intersection / union;
107
+ if (score >= threshold && (!best || score > best.score)) {
108
+ best = { entry, score };
109
+ }
110
+ }
111
+
112
+ return best?.entry;
113
+ }
114
+
115
+ /** Explicit mutation — updates LRU timestamp and hit count. */
116
+ export function touchCacheEntry(entry: CacheEntry): void {
117
+ entry.lastUsed = Date.now();
118
+ entry.hits++;
119
+ }
120
+
121
+ /** Convenience: pure lookup composed with LRU touch.
122
+ * @deprecated Prefer explicit `lookupCache` + `touchCacheEntry` at call sites. */
123
+ export function findCachedCategory(
124
+ entries: CacheEntry[],
125
+ prompt: string,
126
+ threshold: number,
127
+ ): string | undefined {
128
+ const entry = lookupCache(entries, prompt, threshold);
129
+ if (entry) {
130
+ touchCacheEntry(entry);
131
+ return entry.category;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ export function updateCache(
137
+ entries: CacheEntry[],
138
+ prompt: string,
139
+ category: string,
140
+ maxEntries: number,
141
+ ): CacheEntry[] {
142
+ const normalized = normalize(prompt);
143
+ const idx = entries.findIndex((e) => e.normalized === normalized);
144
+
145
+ if (idx !== -1) {
146
+ const updated = [...entries];
147
+ updated[idx] = {
148
+ ...updated[idx],
149
+ category,
150
+ lastUsed: Date.now(),
151
+ hits: updated[idx].hits + 1,
152
+ };
153
+ return evictIfNeeded(updated, maxEntries);
154
+ }
155
+
156
+ return evictIfNeeded(
157
+ [...entries, { normalized, category, lastUsed: Date.now(), hits: 1, seq: nextSeq++ }],
158
+ maxEntries,
159
+ );
160
+ }
161
+
162
+ export function cachePath(cwd: string, configuredPath?: string): string {
163
+ if (configuredPath) {
164
+ if (configuredPath.startsWith("/")) return configuredPath;
165
+ if (configuredPath.startsWith("~")) {
166
+ return (process.env.HOME ?? "/tmp") + configuredPath.slice(1);
167
+ }
168
+ return join(cwd, configuredPath);
169
+ }
170
+ return join(cwd, ".pi", "bifrost-cache.jsonl");
171
+ }
@@ -0,0 +1,107 @@
1
+ import type { ClassifierModel } from "./classifier.ts";
2
+ import { classify as regexClassify, type RouteRule } from "./routing.ts";
3
+ import { debug, debugMeasure } from "./debug.ts";
4
+
5
+ // ── ADT result type ────────────────────────────────────────────
6
+
7
+ export type ClassificationSource = "cache" | "classifier" | "regex";
8
+
9
+ export type ClassificationResult =
10
+ | { readonly kind: "classified"; readonly tier: string; readonly source: ClassificationSource }
11
+ | { readonly kind: "fallback"; readonly tier: string }
12
+ | { readonly kind: "unclassified" };
13
+
14
+ // ── Pipeline dependencies ──────────────────────────────────────
15
+
16
+ /**
17
+ * Dependencies injected into the pipeline. All are in-process.
18
+ *
19
+ * `cacheLookup` may internally mutate its backing store for LRU
20
+ * tracking — this is an accepted impurity (see ADR candidate #4).
21
+ */
22
+ export interface PipelineDeps {
23
+ /** Query cache. Returns tier or undefined. */
24
+ readonly cacheLookup: (text: string) => string | undefined;
25
+ /** Classifier models in priority order. Empty array = skip LLM. */
26
+ readonly classifierModels: readonly ClassifierModel[];
27
+ /** Invoke the LLM classifier for a single model. Returns tier or undefined. */
28
+ readonly classifyWithLLM: (
29
+ model: ClassifierModel,
30
+ text: string,
31
+ tiers: readonly string[],
32
+ ) => Promise<string | undefined>;
33
+ /** Regex routing rules. First match wins. */
34
+ readonly regexRules: readonly RouteRule[];
35
+ /** Default tier when nothing matches. */
36
+ readonly defaultTier: string | undefined;
37
+ /** Known tier names, from config.models keys. */
38
+ readonly tiers: readonly string[];
39
+ }
40
+
41
+ // ── Pipeline interface ─────────────────────────────────────────
42
+
43
+ export interface ClassificationPipeline {
44
+ readonly classify: (text: string) => Promise<ClassificationResult>;
45
+ }
46
+
47
+ // ── Factory ────────────────────────────────────────────────────
48
+
49
+ export function createPipeline(deps: PipelineDeps): ClassificationPipeline {
50
+ const {
51
+ cacheLookup,
52
+ classifierModels,
53
+ classifyWithLLM,
54
+ regexRules,
55
+ defaultTier,
56
+ tiers,
57
+ } = deps;
58
+
59
+ async function classify(text: string): Promise<ClassificationResult> {
60
+ if (tiers.length === 0) return { kind: "unclassified" };
61
+
62
+ // Stage 1: cache lookup
63
+ const endCache = debugMeasure("pipeline", "cache");
64
+ const cached = cacheLookup(text);
65
+ endCache({ hit: !!cached });
66
+ if (cached && tiers.includes(cached)) {
67
+ debug("pipeline", "result", { source: "cache", tier: cached });
68
+ return { kind: "classified", tier: cached, source: "cache" };
69
+ }
70
+
71
+ // Stage 2: LLM classifier — try each model in priority order
72
+ for (const model of classifierModels) {
73
+ try {
74
+ const endLLM = debugMeasure("pipeline", "classifier.attempt");
75
+ const tier = await classifyWithLLM(model, text, tiers);
76
+ const modelId = model.kind === "registry" ? model.model.id : model.id;
77
+ endLLM({ model: modelId, tier });
78
+ if (tier && tiers.includes(tier)) {
79
+ debug("pipeline", "result", { source: "classifier", tier });
80
+ return { kind: "classified", tier, source: "classifier" };
81
+ }
82
+ } catch (err) {
83
+ debug("pipeline", "classifier.error", { error: String(err) });
84
+ console.error(`[bifrost] classifier model failed: ${err}`);
85
+ }
86
+ }
87
+
88
+ // Stage 3: regex rules
89
+ const endRegex = debugMeasure("pipeline", "regex");
90
+ const regex = regexClassify(text, regexRules);
91
+ endRegex({ match: !!regex, tier: regex });
92
+ if (regex && tiers.includes(regex)) {
93
+ debug("pipeline", "result", { source: "regex", tier: regex });
94
+ return { kind: "classified", tier: regex, source: "regex" };
95
+ }
96
+
97
+ // Stage 4: default fallback
98
+ debug("pipeline", "result", { source: "fallback", tier: defaultTier });
99
+ if (defaultTier) {
100
+ return { kind: "fallback", tier: defaultTier };
101
+ }
102
+
103
+ return { kind: "unclassified" };
104
+ }
105
+
106
+ return { classify };
107
+ }