@xynogen/pix-9router 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Pi extension — 9Router provider + fetch/search tools via router API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/data.ts CHANGED
@@ -41,6 +41,12 @@ export interface RouterModel {
41
41
  max_tokens?: number;
42
42
  maxTokens?: number;
43
43
  owned_by?: string;
44
+ /** 9router v1 API nests these inside capabilities */
45
+ capabilities?: {
46
+ contextWindow?: number;
47
+ maxOutput?: number;
48
+ [key: string]: unknown;
49
+ };
44
50
  }
45
51
 
46
52
  interface RouterModelsResponse {
package/src/fetch.ts CHANGED
@@ -264,6 +264,7 @@ export default function registerFetch(pi: ExtensionAPI): void {
264
264
  pi.registerTool({
265
265
  name: "fetch",
266
266
  label: "Fetch",
267
+ renderShell: "self",
267
268
  description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
268
269
  promptSnippet:
269
270
  "fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
@@ -282,6 +282,17 @@ const renderTheme = {
282
282
  bold: (text: string) => `*${text}*`,
283
283
  } as never;
284
284
 
285
+ function captureRegisteredTool(register: (pi: never) => void): Record<string, unknown> {
286
+ let tool: Record<string, unknown> | undefined;
287
+ register({
288
+ registerTool(value: Record<string, unknown>) {
289
+ tool = value;
290
+ },
291
+ } as never);
292
+ if (!tool) throw new Error("tool was not registered");
293
+ return tool;
294
+ }
295
+
285
296
  function renderRegisteredResult(
286
297
  register: (pi: never) => void,
287
298
  result: Record<string, unknown>,
@@ -313,6 +324,11 @@ function renderRegisteredResult(
313
324
  }
314
325
 
315
326
  describe("fetch and search compact renderers", () => {
327
+ it("use the self-rendered shell so status marks have no leading box padding", () => {
328
+ expect(captureRegisteredTool(registerFetch).renderShell).toBe("self");
329
+ expect(captureRegisteredTool(registerSearch).renderShell).toBe("self");
330
+ });
331
+
316
332
  it("renders fallback and failure rows from metadata", () => {
317
333
  const fetchRow = renderRegisteredResult(registerFetch, {
318
334
  content: [{ type: "text", text: "fallback body" }],
@@ -1,48 +1,12 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { ModelsDevModel, RouterModel } from "./data.ts";
3
-
4
- // ── Re-export internal helpers for testing via module augmentation ────────────
5
- // provider.ts exports only the default fn; we test the pure mapping logic
6
- // inline here to avoid coupling tests to private internals.
7
-
8
- const DEFAULT_CONTEXT_WINDOW = 128_000;
9
- const DEFAULT_MAX_TOKENS = 16_384;
10
-
11
- const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
12
-
13
- function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
14
- if (devModel?.modalities?.input) {
15
- const inputs = devModel.modalities.input.filter(
16
- (i): i is "text" | "image" => i === "text" || i === "image",
17
- );
18
- if (inputs.length > 0) return inputs;
19
- }
20
- const id = model.id ?? "";
21
- if (IMAGE_CAPABLE_PATTERNS.some((p) => p.test(id))) return ["text", "image"];
22
- return ["text"];
23
- }
24
-
25
- function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
26
- return model.name || devModel?.name || model.id || "unknown";
27
- }
28
-
29
- function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
30
- return (
31
- model.context_window ||
32
- model.contextWindow ||
33
- devModel?.limit?.context ||
34
- DEFAULT_CONTEXT_WINDOW
35
- );
36
- }
37
-
38
- function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
39
- return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
40
- }
41
-
42
- function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
43
- if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
44
- return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
45
- }
3
+ import {
4
+ getContextWindow,
5
+ getInputTypes,
6
+ getMaxTokens,
7
+ getModelName,
8
+ getReasoning,
9
+ } from "./provider.ts";
46
10
 
47
11
  // ── getInputTypes ────────────────────────────────────────────────────────────
48
12
 
@@ -129,12 +93,37 @@ describe("getContextWindow", () => {
129
93
  expect(getContextWindow({ id: "x", contextWindow: 64_000 })).toBe(64_000);
130
94
  });
131
95
 
96
+ it("falls back to capabilities.contextWindow (9router v1 nested shape)", () => {
97
+ expect(getContextWindow({ id: "x", capabilities: { contextWindow: 1_000_000 } })).toBe(
98
+ 1_000_000,
99
+ );
100
+ });
101
+
102
+ it("top-level fields beat capabilities.contextWindow", () => {
103
+ expect(
104
+ getContextWindow({
105
+ id: "x",
106
+ contextWindow: 64_000,
107
+ capabilities: { contextWindow: 1_000_000 },
108
+ }),
109
+ ).toBe(64_000);
110
+ });
111
+
112
+ it("capabilities.contextWindow beats devModel.limit.context", () => {
113
+ expect(
114
+ getContextWindow(
115
+ { id: "x", capabilities: { contextWindow: 1_000_000 } },
116
+ { id: "x", limit: { context: 200_000 } },
117
+ ),
118
+ ).toBe(1_000_000);
119
+ });
120
+
132
121
  it("falls back to devModel.limit.context", () => {
133
122
  expect(getContextWindow({ id: "x" }, { id: "x", limit: { context: 200_000 } })).toBe(200_000);
134
123
  });
135
124
 
136
125
  it("falls back to DEFAULT_CONTEXT_WINDOW", () => {
137
- expect(getContextWindow({ id: "x" })).toBe(DEFAULT_CONTEXT_WINDOW);
126
+ expect(getContextWindow({ id: "x" })).toBe(128_000);
138
127
  });
139
128
  });
140
129
 
@@ -151,12 +140,40 @@ describe("getMaxTokens", () => {
151
140
  expect(getMaxTokens({ id: "x", maxTokens: 8_192 })).toBe(8_192);
152
141
  });
153
142
 
143
+ it("falls back to capabilities.maxOutput (9router v1 nested shape)", () => {
144
+ expect(getMaxTokens({ id: "x", capabilities: { maxOutput: 64_000 } })).toBe(64_000);
145
+ });
146
+
147
+ it("top-level fields beat capabilities.maxOutput", () => {
148
+ expect(getMaxTokens({ id: "x", maxTokens: 8_192, capabilities: { maxOutput: 64_000 } })).toBe(
149
+ 8_192,
150
+ );
151
+ });
152
+
153
+ it("capabilities.maxOutput beats devModel.limit.output", () => {
154
+ expect(
155
+ getMaxTokens(
156
+ { id: "x", capabilities: { maxOutput: 64_000 } },
157
+ { id: "x", limit: { output: 16_000 } },
158
+ ),
159
+ ).toBe(64_000);
160
+ });
161
+
154
162
  it("falls back to devModel.limit.output", () => {
155
163
  expect(getMaxTokens({ id: "x" }, { id: "x", limit: { output: 16_000 } })).toBe(16_000);
156
164
  });
157
165
 
158
166
  it("falls back to DEFAULT_MAX_TOKENS", () => {
159
- expect(getMaxTokens({ id: "x" })).toBe(DEFAULT_MAX_TOKENS);
167
+ expect(getMaxTokens({ id: "x" })).toBe(16_384);
168
+ });
169
+
170
+ it("handles the PR #9 regression case (qwen3-coder-flash nested capabilities)", () => {
171
+ const model: RouterModel = {
172
+ id: "alims-intl/qwen3-coder-flash-2025-07-28",
173
+ capabilities: { contextWindow: 1_000_000, maxOutput: 64_000 },
174
+ };
175
+ expect(getContextWindow(model)).toBe(1_000_000);
176
+ expect(getMaxTokens(model)).toBe(64_000);
160
177
  });
161
178
  });
162
179
 
package/src/provider.ts CHANGED
@@ -26,7 +26,7 @@ const ZERO_COST = {
26
26
  // Fallback pattern-based detection if models.dev lookup fails
27
27
  const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
28
28
 
29
- function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
29
+ export function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
30
30
  if (devModel?.modalities?.input) {
31
31
  const inputs = devModel.modalities.input.filter(
32
32
  (i): i is "text" | "image" => i === "text" || i === "image",
@@ -38,24 +38,31 @@ function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" |
38
38
  return ["text"];
39
39
  }
40
40
 
41
- function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
41
+ export function getModelName(model: RouterModel, devModel?: ModelsDevModel): string {
42
42
  return model.name || devModel?.name || model.id || "unknown";
43
43
  }
44
44
 
45
- function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
45
+ export function getContextWindow(model: RouterModel, devModel?: ModelsDevModel): number {
46
46
  return (
47
47
  model.context_window ||
48
48
  model.contextWindow ||
49
+ model.capabilities?.contextWindow ||
49
50
  devModel?.limit?.context ||
50
51
  DEFAULT_CONTEXT_WINDOW
51
52
  );
52
53
  }
53
54
 
54
- function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
55
- return model.max_tokens || model.maxTokens || devModel?.limit?.output || DEFAULT_MAX_TOKENS;
55
+ export function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): number {
56
+ return (
57
+ model.max_tokens ||
58
+ model.maxTokens ||
59
+ model.capabilities?.maxOutput ||
60
+ devModel?.limit?.output ||
61
+ DEFAULT_MAX_TOKENS
62
+ );
56
63
  }
57
64
 
58
- function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
65
+ export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
59
66
  if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
60
67
  return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
61
68
  }
package/src/search.ts CHANGED
@@ -293,6 +293,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
293
293
  pi.registerTool({
294
294
  name: "search",
295
295
  label: "Search",
296
+ renderShell: "self",
296
297
  description: "Web or news search via exa. Returns title, url, and snippet for each result.",
297
298
  promptSnippet:
298
299
  "search(query, search_type, max_results?) — search_type: 'web' or 'news'. Defaults to 5 results, max 10.",
@@ -416,9 +416,9 @@ const theme = {
416
416
  } as never;
417
417
 
418
418
  function captureTranscribeRenderer() {
419
- let tool: Record<string, (...args: never[]) => unknown> | undefined;
419
+ let tool: Record<string, unknown> | undefined;
420
420
  registerTranscribe({
421
- registerTool(value: Record<string, (...args: never[]) => unknown>) {
421
+ registerTool(value: Record<string, unknown>) {
422
422
  tool = value;
423
423
  },
424
424
  } as never);
@@ -440,7 +440,8 @@ function renderTranscribe(
440
440
  invalidate: () => {},
441
441
  };
442
442
  const tool = captureTranscribeRenderer();
443
- tool.renderResult?.(result as never, { expanded, isPartial: false } as never, theme, {
443
+ const renderResult = tool.renderResult as ((...args: never[]) => unknown) | undefined;
444
+ renderResult?.(result as never, { expanded, isPartial: false } as never, theme, {
444
445
  lastComponent: component,
445
446
  isError,
446
447
  state: { collapsed: true },
@@ -451,6 +452,10 @@ function renderTranscribe(
451
452
  }
452
453
 
453
454
  describe("transcribe compact renderer", () => {
455
+ it("uses the self-rendered shell so the status mark has no leading box padding", () => {
456
+ expect(captureTranscribeRenderer().renderShell).toBe("self");
457
+ });
458
+
454
459
  it("summarizes inline and written transcripts", () => {
455
460
  const inline = renderTranscribe({
456
461
  content: [{ type: "text", text: "full transcript" }],
package/src/transcribe.ts CHANGED
@@ -358,6 +358,7 @@ export default function registerTranscribe(pi: ExtensionAPI): void {
358
358
  pi.registerTool({
359
359
  name: "transcribe",
360
360
  label: "Transcribe",
361
+ renderShell: "self",
361
362
  description:
362
363
  "Convert speech to text. Transcribes an audio file using the 9Router audio transcription API (Deepgram Nova 3). Optionally writes the full text to a file on disk.",
363
364
  promptSnippet: