@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 +1 -1
- package/src/data.ts +6 -0
- package/src/fetch.ts +1 -0
- package/src/format.test.ts +16 -0
- package/src/provider.test.ts +62 -45
- package/src/provider.ts +13 -6
- package/src/search.ts +1 -0
- package/src/transcribe.test.ts +8 -3
- package/src/transcribe.ts +1 -0
package/package.json
CHANGED
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.",
|
package/src/format.test.ts
CHANGED
|
@@ -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" }],
|
package/src/provider.test.ts
CHANGED
|
@@ -1,48 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { ModelsDevModel, RouterModel } from "./data.ts";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.",
|
package/src/transcribe.test.ts
CHANGED
|
@@ -416,9 +416,9 @@ const theme = {
|
|
|
416
416
|
} as never;
|
|
417
417
|
|
|
418
418
|
function captureTranscribeRenderer() {
|
|
419
|
-
let tool: Record<string,
|
|
419
|
+
let tool: Record<string, unknown> | undefined;
|
|
420
420
|
registerTranscribe({
|
|
421
|
-
registerTool(value: Record<string,
|
|
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
|
|
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:
|