@xynogen/pix-9router 0.7.0 → 0.8.1

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.1",
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.13.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),
@@ -93,7 +129,10 @@ export function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): num
93
129
 
94
130
  export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
95
131
  if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
96
- return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
132
+ // ponytail: Detect GPT-5/6 offline. Add metadata for new families instead of a network wait.
133
+ return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet|(?:^|\/)gpt-[56](?:[.-]|$)/i.test(
134
+ model.id ?? "",
135
+ );
97
136
  }
98
137
 
99
138
  export default async function registerProvider(pi: ExtensionAPI): Promise<void> {
@@ -129,6 +168,109 @@ export default async function registerProvider(pi: ExtensionAPI): Promise<void>
129
168
  .catch(() => {});
130
169
  }
131
170
 
171
+ export async function classifySystemOne(
172
+ model: { id: string; baseUrl?: string; api?: string; provider?: string },
173
+ context: { state: Record<string, unknown>; questions: Record<string, any> },
174
+ apiKey: string,
175
+ options?: { signal?: AbortSignal; headers?: Record<string, string> },
176
+ ): Promise<ClassifierResult> {
177
+ const output = {
178
+ api: model.api ?? "typesafe-system-one",
179
+ provider: model.provider ?? "9router",
180
+ model: model.id,
181
+ answers: {} as Record<string, any>,
182
+ stopReason: "stop" as const,
183
+ timestamp: Date.now(),
184
+ };
185
+
186
+ try {
187
+ const wireQuestions = Object.fromEntries(
188
+ Object.entries(context.questions ?? {}).map(([key, q]) => [
189
+ key,
190
+ q.type === "bool" ? { ...q, type: "noul" } : q,
191
+ ]),
192
+ );
193
+
194
+ const base = (model.baseUrl || routerBaseUrl()).replace(/\/+$/, "");
195
+ const url = `${base}/systemone`;
196
+ const res = await fetch(url, {
197
+ method: "POST",
198
+ signal: ioTimeoutSignal(options?.signal),
199
+ headers: {
200
+ Authorization: `Bearer ${apiKey}`,
201
+ "Content-Type": "application/json",
202
+ "User-Agent": "pi-coding-agent",
203
+ ...(options?.headers ?? {}),
204
+ },
205
+ body: JSON.stringify({
206
+ model: model.id,
207
+ state: context.state,
208
+ questions: wireQuestions,
209
+ }),
210
+ });
211
+
212
+ if (!res.ok) {
213
+ const errText = await res.text().catch(() => "");
214
+ return {
215
+ ...output,
216
+ stopReason: "error" as const,
217
+ errorMessage: `9router /systemone returned ${res.status}: ${errText}`,
218
+ };
219
+ }
220
+
221
+ const data = (await res.json()) as any;
222
+ const answers: Record<string, any> = {};
223
+
224
+ for (const [key, q] of Object.entries(context.questions ?? {})) {
225
+ const ans = data?.answers?.[key];
226
+ if (!ans) continue;
227
+ if (q.type === "bool") {
228
+ answers[key] = {
229
+ type: "bool",
230
+ probability: typeof ans.noul === "number" ? ans.noul : (ans.probability ?? 0),
231
+ };
232
+ } else if (q.type === "choice") {
233
+ answers[key] = {
234
+ type: "choice",
235
+ choice: ans.choice,
236
+ probabilities: ans.probabilities ?? {},
237
+ confidence: ans.confidence ?? 0,
238
+ };
239
+ } else if (q.type === "score") {
240
+ answers[key] = {
241
+ type: "score",
242
+ score: ans.score ?? 0,
243
+ confidence: ans.confidence ?? 0,
244
+ };
245
+ } else {
246
+ answers[key] = ans;
247
+ }
248
+ }
249
+
250
+ const inputTokens = data?.usage?.input_tokens ?? 0;
251
+ const outputTokens = data?.usage?.output_tokens ?? 0;
252
+
253
+ return {
254
+ ...output,
255
+ answers,
256
+ usage: {
257
+ input: inputTokens,
258
+ output: outputTokens,
259
+ cacheRead: 0,
260
+ cacheWrite: 0,
261
+ totalTokens: inputTokens + outputTokens,
262
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
263
+ },
264
+ };
265
+ } catch (error) {
266
+ return {
267
+ ...output,
268
+ stopReason: "error" as const,
269
+ errorMessage: error instanceof Error ? error.message : String(error),
270
+ };
271
+ }
272
+ }
273
+
132
274
  function providerConfig(
133
275
  apiKey: string,
134
276
  models: RouterModel[],
@@ -141,6 +283,15 @@ function providerConfig(
141
283
  api: "openai-completions",
142
284
  headers: { "User-Agent": "pi-coding-agent" },
143
285
  models: models.map(toModelConfig(devIndex)),
286
+ classifiers: {
287
+ "typesafe-system-one": {
288
+ classify: (
289
+ model: Parameters<typeof classifySystemOne>[0],
290
+ context: Parameters<typeof classifySystemOne>[1],
291
+ options?: Parameters<typeof classifySystemOne>[3],
292
+ ) => classifySystemOne(model, context, apiKey, options),
293
+ },
294
+ },
144
295
  // Live fetch on /model refresh — bypasses the disk cache.
145
296
  // Pi calls refreshModels once per provider at every startup with
146
297
  // allowNetwork:false (awaited, no timeout) — never touch the network