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/classifier.ts ADDED
@@ -0,0 +1,282 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import { spawn } from "node:child_process";
4
+ import { debug } from "./debug.ts";
5
+
6
+ // ── Classifier model — union type, no type-cast lies ─────────
7
+
8
+ /** A model from pi's registry — full auth, provider support. */
9
+ interface RegistryClassifier {
10
+ readonly kind: "registry";
11
+ readonly model: Model<Api>;
12
+ }
13
+
14
+ /** A direct HTTP endpoint — no auth, OpenAI-compatible only. */
15
+ interface EndpointClassifier {
16
+ readonly kind: "endpoint";
17
+ readonly id: string;
18
+ readonly baseUrl: string;
19
+ }
20
+
21
+ /** Union: either a registry model or a raw endpoint. */
22
+ export type ClassifierModel = RegistryClassifier | EndpointClassifier;
23
+
24
+ function classifierBaseUrl(cm: ClassifierModel): string {
25
+ return cm.kind === "endpoint" ? cm.baseUrl : cm.model.baseUrl;
26
+ }
27
+
28
+ function classifierId(cm: ClassifierModel): string {
29
+ return cm.kind === "registry" ? cm.model.id : cm.id;
30
+ }
31
+
32
+ function isOpenAiCompatibleEndpoint(cm: ClassifierModel): boolean {
33
+ if (cm.kind === "endpoint") return true; // endpoint config implies OpenAI-compatible
34
+ const api = cm.model.api;
35
+ return (
36
+ api === "openai-completions" ||
37
+ api === "openai-responses" ||
38
+ api === "openai-codex-responses" ||
39
+ api === "azure-openai-responses" ||
40
+ api === "mistral-conversations"
41
+ );
42
+ }
43
+
44
+ const DEFAULT_SYSTEM_PROMPT =
45
+ "You are a routing classifier. Classify each request into exactly one tier." +
46
+ " Respond with only the tier name. No explanation, no punctuation.";
47
+
48
+ export interface ClassifierOptions {
49
+ systemPrompt?: string;
50
+ maxTokens?: number;
51
+ temperature?: number;
52
+ method?: "direct" | "subprocess" | "auto";
53
+ }
54
+
55
+ export function categoryLabel(category: string): string {
56
+ return category;
57
+ }
58
+
59
+ export function classificationPrompt(
60
+ categories: readonly string[],
61
+ userPrompt: string,
62
+ ): string {
63
+ return (
64
+ `Categories: ${categories.map(categoryLabel).join(", ")}\n\n` +
65
+ `Classify the request into exactly one category. Respond with only the category name.\n\n` +
66
+ `Request: ${userPrompt}\n\n` +
67
+ `Category:`
68
+ );
69
+ }
70
+
71
+ export function extractCategory(text: string, categories: readonly string[]): string | undefined {
72
+ // Strip punctuation and whitespace — LLM may output "frontier." or "frontier\n".
73
+ const needle = text.trim().toLowerCase().replace(/[^\p{L}\p{N}]+$/gu, "").replace(/^[^\p{L}\p{N}]+/gu, "");
74
+ return categories.find((cat) => cat.toLowerCase() === needle);
75
+ }
76
+
77
+ function piCommand(): { command: string; args: string[] } {
78
+ // Reuse the current Node binary and script path for subprocess.
79
+ // Falls back to bare "pi" if argv[1] is unavailable (e.g. bundled executable).
80
+ const script = process.argv[1];
81
+ if (script) return { command: process.execPath, args: [script] };
82
+ return { command: "pi", args: [] };
83
+ }
84
+
85
+ async function classifyWithDirectHttp(
86
+ ctx: ExtensionContext,
87
+ classifierModel: ClassifierModel,
88
+ categories: readonly string[],
89
+ prompt: string,
90
+ options: ClassifierOptions = {},
91
+ ): Promise<string | undefined> {
92
+ if (!isOpenAiCompatibleEndpoint(classifierModel)) {
93
+ return undefined;
94
+ }
95
+
96
+ // Auth: only for registry models. Endpoint config assumes no auth needed.
97
+ let apiKey: string | undefined;
98
+ let authHeaders: Record<string, string> = {};
99
+ if (classifierModel.kind === "registry") {
100
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(classifierModel.model);
101
+ if (auth.ok) {
102
+ apiKey = auth.apiKey;
103
+ authHeaders = auth.headers ?? {};
104
+ }
105
+ }
106
+
107
+ const systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
108
+ const maxTokens = options.maxTokens ?? 20;
109
+ const temperature = options.temperature ?? 0;
110
+ const userPrompt = classificationPrompt(categories, prompt);
111
+
112
+ const body = {
113
+ model: classifierId(classifierModel),
114
+ messages: [
115
+ { role: "system", content: systemPrompt },
116
+ { role: "user", content: userPrompt },
117
+ ],
118
+ max_tokens: maxTokens,
119
+ temperature: temperature,
120
+ stream: false,
121
+ };
122
+
123
+ const base = classifierBaseUrl(classifierModel);
124
+ const baseUrl = base.endsWith("/") ? base : `${base}/`;
125
+ const url = new URL("chat/completions", baseUrl).toString();
126
+ const headers: Record<string, string> = {
127
+ "Content-Type": "application/json",
128
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
129
+ ...authHeaders,
130
+ };
131
+
132
+ try {
133
+ const response = await fetch(url, {
134
+ method: "POST",
135
+ headers,
136
+ body: JSON.stringify(body),
137
+ signal: ctx.signal ?? (typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal
138
+ ? AbortSignal.timeout(30_000)
139
+ : void 0),
140
+ });
141
+
142
+ if (!response.ok) {
143
+ console.error(
144
+ `[bifrost] classifier HTTP ${response.status} from ${classifierBaseUrl(classifierModel)}`,
145
+ );
146
+ return undefined;
147
+ }
148
+
149
+ const data = (await response.json()) as {
150
+ choices?: Array<{ message?: { content?: string } }>;
151
+ };
152
+ const content = data.choices?.[0]?.message?.content?.trim();
153
+ if (!content) {
154
+ debug("classifier", "http.empty_response", { url: classifierBaseUrl(classifierModel) });
155
+ return undefined;
156
+ }
157
+
158
+ const result = extractCategory(content, categories);
159
+ debug("classifier", "http.done", {
160
+ model: classifierId(classifierModel),
161
+ raw: content.slice(0, 100),
162
+ tier: result,
163
+ });
164
+ return result;
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ async function classifyWithSubprocess(
171
+ _ctx: ExtensionContext,
172
+ classifierModel: ClassifierModel,
173
+ categories: readonly string[],
174
+ prompt: string,
175
+ options: ClassifierOptions = {},
176
+ ): Promise<string | undefined> {
177
+ // Subprocess only works with registry models (needs provider/id for --model).
178
+ if (classifierModel.kind !== "registry") return undefined;
179
+ const model = classifierModel.model;
180
+ debug("classifier", "subprocess.start", { model: `${model.provider}/${model.id}` });
181
+
182
+ const systemPrompt = options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
183
+ const userPrompt = classificationPrompt(categories, prompt);
184
+ const { command, args } = piCommand();
185
+
186
+ const piArgs = [
187
+ ...args,
188
+ "--no-extensions",
189
+ "--no-prompt-templates",
190
+ "--no-context-files",
191
+ "--no-approve",
192
+ "--no-session",
193
+ "--print",
194
+ "--system-prompt",
195
+ systemPrompt,
196
+ "-p",
197
+ userPrompt,
198
+ "--model",
199
+ `${model.provider}/${model.id}`,
200
+ ];
201
+
202
+ return new Promise((resolve) => {
203
+ const child = spawn(command, piArgs, {
204
+ stdio: ["ignore", "pipe", "pipe"],
205
+ env: process.env,
206
+ });
207
+
208
+ let stdout = "";
209
+ let stderr = "";
210
+ const MAX_CHUNK = 2000;
211
+ child.stdout.setEncoding("utf8");
212
+ child.stderr.setEncoding("utf8");
213
+ child.stdout.on("data", (chunk: string) => {
214
+ if (stdout.length < MAX_CHUNK) stdout += chunk;
215
+ });
216
+ child.stderr.on("data", (chunk: string) => {
217
+ if (stderr.length < MAX_CHUNK) stderr += chunk;
218
+ });
219
+
220
+ const timer = setTimeout(() => {
221
+ child.kill("SIGTERM");
222
+ console.error(`[bifrost] classifier subprocess timed out`);
223
+ resolve(undefined);
224
+ }, 120_000);
225
+
226
+ child.on("error", (err: Error) => {
227
+ clearTimeout(timer);
228
+ console.error(`[bifrost] classifier subprocess error: ${err}`);
229
+ resolve(undefined);
230
+ });
231
+
232
+ child.on("close", (code: number | null) => {
233
+ clearTimeout(timer);
234
+ if (code !== 0) {
235
+ debug("classifier", "subprocess.error", {
236
+ model: `${model.provider}/${model.id}`,
237
+ exitCode: code,
238
+ stderr: stderr.slice(0, 200),
239
+ });
240
+ console.error(
241
+ `[bifrost] classifier subprocess exited ${code}: ${stderr.slice(0, 500)}`,
242
+ );
243
+ resolve(undefined);
244
+ return;
245
+ }
246
+ const result = extractCategory(stdout, categories);
247
+ debug("classifier", "subprocess.done", {
248
+ model: `${model.provider}/${model.id}`,
249
+ raw: stdout.trim().slice(0, 100),
250
+ tier: result,
251
+ });
252
+ resolve(result);
253
+ });
254
+ });
255
+ }
256
+
257
+ export async function classifyWithLLM(
258
+ ctx: ExtensionContext,
259
+ classifierModel: ClassifierModel,
260
+ categories: readonly string[],
261
+ prompt: string,
262
+ options: ClassifierOptions = {},
263
+ ): Promise<string | undefined> {
264
+ const method = options.method ?? "auto";
265
+
266
+ if (method === "direct" || method === "auto") {
267
+ const direct = await classifyWithDirectHttp(
268
+ ctx,
269
+ classifierModel,
270
+ categories,
271
+ prompt,
272
+ options,
273
+ );
274
+ if (direct) return direct;
275
+ }
276
+
277
+ if (method === "subprocess" || method === "auto") {
278
+ return classifyWithSubprocess(ctx, classifierModel, categories, prompt, options);
279
+ }
280
+
281
+ return undefined;
282
+ }