@xynogen/pix-9router 0.7.0 → 0.8.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/README.md CHANGED
@@ -33,8 +33,22 @@ The standard names win when both sets exist.
33
33
  pi install npm:@xynogen/pix-9router
34
34
  ```
35
35
 
36
- This package is standalone and opt-in. It is not bundled by `@xynogen/pix-core`.
36
+ > Standalone and opt-in. [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core) does not bundle it. It needs a 9Router API key.
37
+
38
+ ## Full distro
39
+
40
+ This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
41
+
42
+ ```bash
43
+ # Linux / macOS
44
+ curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
45
+ ```
46
+
47
+ ```powershell
48
+ # Windows
49
+ irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
50
+ ```
37
51
 
38
52
  ## License
39
53
 
40
- MIT
54
+ MIT. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension — 9Router model provider",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -45,6 +45,6 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@xynogen/pix-data": "^0.5.1",
48
- "@xynogen/pix-runtime": "^0.10.2"
48
+ "@xynogen/pix-runtime": "^0.12.0"
49
49
  }
50
50
  }
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import registerProvider from "./provider.js";
15
+ import registerProvider from "./provider.ts";
16
16
 
17
17
  export default async function (pi: ExtensionAPI): Promise<void> {
18
18
  await registerProvider(pi);
package/src/provider.ts CHANGED
@@ -9,11 +9,11 @@
9
9
  * ROUTER_API_KEY — bearer token (required for live model list)
10
10
  */
11
11
 
12
- import type { RefreshModelsContext } from "@earendil-works/pi-ai";
12
+ import type { ClassifierResult, RefreshModelsContext } from "@earendil-works/pi-ai";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
15
- import type { ModelsDevModel, RouterModel } from "./data.js";
16
- import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.js";
15
+ import type { ModelsDevModel, RouterModel } from "./data.ts";
16
+ import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.ts";
17
17
 
18
18
  const DEFAULT_CONTEXT_WINDOW = 128_000;
19
19
  const DEFAULT_MAX_TOKENS = 16_384;
@@ -28,6 +28,28 @@ const ZERO_COST = {
28
28
  // Fallback pattern-based detection if models.dev lookup fails
29
29
  const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
30
30
 
31
+ /** Known patterns for classifier models (System One protocol) */
32
+ export const CLASSIFIER_PATTERNS = [
33
+ /\bjev\b/i,
34
+ /\bspan\b/i,
35
+ /\bsolar-decide\b/i,
36
+ /\bclassifier\b/i,
37
+ ];
38
+
39
+ export function isClassifierModel(model: RouterModel): boolean {
40
+ const id = model.id ?? "";
41
+ const name = model.name ?? "";
42
+ const custom = (process.env.NINEROUTER_CLASSIFIER_MODELS || "")
43
+ .split(",")
44
+ .map((s) => s.trim().toLowerCase())
45
+ .filter(Boolean);
46
+
47
+ if (custom.some((c) => id.toLowerCase().includes(c) || name.toLowerCase().includes(c))) {
48
+ return true;
49
+ }
50
+ return CLASSIFIER_PATTERNS.some((p) => p.test(id) || p.test(name));
51
+ }
52
+
31
53
  interface RouterModelsResponse {
32
54
  data?: RouterModel[];
33
55
  }
@@ -38,10 +60,24 @@ const COMPAT = {
38
60
  maxTokensField: "max_tokens",
39
61
  } as const;
40
62
 
41
- function toModelConfig(devIndex: Map<string, ModelsDevModel>) {
63
+ export function toModelConfig(devIndex: Map<string, ModelsDevModel>) {
42
64
  return (model: RouterModel) => {
43
65
  const id = model.id ?? "";
44
66
  const devModel = lookupInIndex(id, devIndex);
67
+
68
+ if (isClassifierModel(model)) {
69
+ return {
70
+ type: "classifier" as const,
71
+ id,
72
+ name: getModelName(model, devModel),
73
+ api: "typesafe-system-one",
74
+ baseUrl: routerBaseUrl(),
75
+ input: ["text" as const],
76
+ cost: ZERO_COST,
77
+ contextWindow: getContextWindow(model, devModel),
78
+ };
79
+ }
80
+
45
81
  return {
46
82
  id,
47
83
  name: getModelName(model, devModel),
@@ -129,6 +165,109 @@ export default async function registerProvider(pi: ExtensionAPI): Promise<void>
129
165
  .catch(() => {});
130
166
  }
131
167
 
168
+ export async function classifySystemOne(
169
+ model: { id: string; baseUrl?: string; api?: string; provider?: string },
170
+ context: { state: Record<string, unknown>; questions: Record<string, any> },
171
+ apiKey: string,
172
+ options?: { signal?: AbortSignal; headers?: Record<string, string> },
173
+ ): Promise<ClassifierResult> {
174
+ const output = {
175
+ api: model.api ?? "typesafe-system-one",
176
+ provider: model.provider ?? "9router",
177
+ model: model.id,
178
+ answers: {} as Record<string, any>,
179
+ stopReason: "stop" as const,
180
+ timestamp: Date.now(),
181
+ };
182
+
183
+ try {
184
+ const wireQuestions = Object.fromEntries(
185
+ Object.entries(context.questions ?? {}).map(([key, q]) => [
186
+ key,
187
+ q.type === "bool" ? { ...q, type: "noul" } : q,
188
+ ]),
189
+ );
190
+
191
+ const base = (model.baseUrl || routerBaseUrl()).replace(/\/+$/, "");
192
+ const url = `${base}/systemone`;
193
+ const res = await fetch(url, {
194
+ method: "POST",
195
+ signal: ioTimeoutSignal(options?.signal),
196
+ headers: {
197
+ Authorization: `Bearer ${apiKey}`,
198
+ "Content-Type": "application/json",
199
+ "User-Agent": "pi-coding-agent",
200
+ ...(options?.headers ?? {}),
201
+ },
202
+ body: JSON.stringify({
203
+ model: model.id,
204
+ state: context.state,
205
+ questions: wireQuestions,
206
+ }),
207
+ });
208
+
209
+ if (!res.ok) {
210
+ const errText = await res.text().catch(() => "");
211
+ return {
212
+ ...output,
213
+ stopReason: "error" as const,
214
+ errorMessage: `9router /systemone returned ${res.status}: ${errText}`,
215
+ };
216
+ }
217
+
218
+ const data = (await res.json()) as any;
219
+ const answers: Record<string, any> = {};
220
+
221
+ for (const [key, q] of Object.entries(context.questions ?? {})) {
222
+ const ans = data?.answers?.[key];
223
+ if (!ans) continue;
224
+ if (q.type === "bool") {
225
+ answers[key] = {
226
+ type: "bool",
227
+ probability: typeof ans.noul === "number" ? ans.noul : (ans.probability ?? 0),
228
+ };
229
+ } else if (q.type === "choice") {
230
+ answers[key] = {
231
+ type: "choice",
232
+ choice: ans.choice,
233
+ probabilities: ans.probabilities ?? {},
234
+ confidence: ans.confidence ?? 0,
235
+ };
236
+ } else if (q.type === "score") {
237
+ answers[key] = {
238
+ type: "score",
239
+ score: ans.score ?? 0,
240
+ confidence: ans.confidence ?? 0,
241
+ };
242
+ } else {
243
+ answers[key] = ans;
244
+ }
245
+ }
246
+
247
+ const inputTokens = data?.usage?.input_tokens ?? 0;
248
+ const outputTokens = data?.usage?.output_tokens ?? 0;
249
+
250
+ return {
251
+ ...output,
252
+ answers,
253
+ usage: {
254
+ input: inputTokens,
255
+ output: outputTokens,
256
+ cacheRead: 0,
257
+ cacheWrite: 0,
258
+ totalTokens: inputTokens + outputTokens,
259
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
260
+ },
261
+ };
262
+ } catch (error) {
263
+ return {
264
+ ...output,
265
+ stopReason: "error" as const,
266
+ errorMessage: error instanceof Error ? error.message : String(error),
267
+ };
268
+ }
269
+ }
270
+
132
271
  function providerConfig(
133
272
  apiKey: string,
134
273
  models: RouterModel[],
@@ -141,6 +280,15 @@ function providerConfig(
141
280
  api: "openai-completions",
142
281
  headers: { "User-Agent": "pi-coding-agent" },
143
282
  models: models.map(toModelConfig(devIndex)),
283
+ classifiers: {
284
+ "typesafe-system-one": {
285
+ classify: (
286
+ model: Parameters<typeof classifySystemOne>[0],
287
+ context: Parameters<typeof classifySystemOne>[1],
288
+ options?: Parameters<typeof classifySystemOne>[3],
289
+ ) => classifySystemOne(model, context, apiKey, options),
290
+ },
291
+ },
144
292
  // Live fetch on /model refresh — bypasses the disk cache.
145
293
  // Pi calls refreshModels once per provider at every startup with
146
294
  // allowNetwork:false (awaited, no timeout) — never touch the network