@xynogen/pix-9router 0.3.21 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.3.21",
3
+ "version": "0.4.0",
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/provider.ts CHANGED
@@ -9,6 +9,7 @@
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
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
14
  import type { ModelsDevModel, RouterModel } from "./data.js";
14
15
  import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.js";
@@ -26,6 +27,33 @@ const ZERO_COST = {
26
27
  // Fallback pattern-based detection if models.dev lookup fails
27
28
  const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
28
29
 
30
+ interface RouterModelsResponse {
31
+ data?: RouterModel[];
32
+ }
33
+
34
+ const COMPAT = {
35
+ supportsDeveloperRole: false,
36
+ supportsUsageInStreaming: false,
37
+ maxTokensField: "max_tokens",
38
+ } as const;
39
+
40
+ function toModelConfig(devIndex: Map<string, ModelsDevModel>) {
41
+ return (model: RouterModel) => {
42
+ const id = model.id ?? "";
43
+ const devModel = lookupInIndex(id, devIndex);
44
+ return {
45
+ id,
46
+ name: getModelName(model, devModel),
47
+ reasoning: getReasoning(model, devModel),
48
+ input: getInputTypes(model, devModel),
49
+ cost: ZERO_COST,
50
+ contextWindow: getContextWindow(model, devModel),
51
+ maxTokens: getMaxTokens(model, devModel),
52
+ compat: COMPAT,
53
+ };
54
+ };
55
+ }
56
+
29
57
  export function getInputTypes(model: RouterModel, devModel?: ModelsDevModel): ("text" | "image")[] {
30
58
  if (devModel?.modalities?.input) {
31
59
  const inputs = devModel.modalities.input.filter(
@@ -82,39 +110,54 @@ export default async function registerProvider(pi: ExtensionAPI): Promise<void>
82
110
  return;
83
111
  }
84
112
 
85
- const [models, devIndex] = await Promise.all([
113
+ // Upstream moved OpenAI `compat` settings from the provider level to the
114
+ // per-model level (ProviderModelConfig.compat). Applied in toModelConfig.
115
+
116
+ // Register from disk cache only — startup must not block on network.
117
+ const cached = routerModels.getCached();
118
+ pi.registerProvider("9router", providerConfig(apiKey, cached, new Map()));
119
+
120
+ // Background refresh; re-registering after startup applies immediately.
121
+ void Promise.all([
86
122
  routerModels.get(),
87
123
  fetchModelsDevIndex().catch(() => new Map<string, ModelsDevModel>()),
88
- ]);
124
+ ])
125
+ .then(([models, devIndex]) =>
126
+ pi.registerProvider("9router", providerConfig(apiKey, models, devIndex)),
127
+ )
128
+ .catch(() => {});
129
+ }
89
130
 
90
- // Upstream moved OpenAI `compat` settings from the provider level to the
91
- // per-model level (ProviderModelConfig.compat). Apply the same shim to every
92
- // registered model.
93
- const COMPAT = {
94
- supportsDeveloperRole: false,
95
- supportsUsageInStreaming: false,
96
- maxTokensField: "max_tokens",
97
- } as const;
98
-
99
- pi.registerProvider("9router", {
131
+ function providerConfig(
132
+ apiKey: string,
133
+ models: RouterModel[],
134
+ devIndex: Map<string, ModelsDevModel>,
135
+ ) {
136
+ return {
100
137
  name: "9Router",
101
138
  baseUrl: routerBaseUrl(),
102
139
  apiKey: "$ROUTER_API_KEY",
103
140
  api: "openai-completions",
104
141
  headers: { "User-Agent": "pi-coding-agent" },
105
- models: models.map((model) => {
106
- const id = model.id ?? "";
107
- const devModel = lookupInIndex(id, devIndex);
108
- return {
109
- id,
110
- name: getModelName(model, devModel),
111
- reasoning: getReasoning(model, devModel),
112
- input: getInputTypes(model, devModel),
113
- cost: ZERO_COST,
114
- contextWindow: getContextWindow(model, devModel),
115
- maxTokens: getMaxTokens(model, devModel),
116
- compat: COMPAT,
117
- };
118
- }),
119
- });
142
+ models: models.map(toModelConfig(devIndex)),
143
+ // Live fetch on /model refresh — bypasses the disk cache.
144
+ // Pi calls refreshModels once per provider at every startup with
145
+ // allowNetwork:false (awaited, no timeout) — never touch the network
146
+ // there; serve the registered snapshot instead.
147
+ async refreshModels({ signal, allowNetwork }: RefreshModelsContext) {
148
+ if (!allowNetwork) return models.map(toModelConfig(devIndex));
149
+ const res = await fetch(`${routerBaseUrl()}/models`, {
150
+ signal: AbortSignal.any([AbortSignal.timeout(8_000), ...(signal ? [signal] : [])]),
151
+ headers: {
152
+ Authorization: `Bearer ${apiKey}`,
153
+ "User-Agent": "pi-coding-agent",
154
+ },
155
+ });
156
+ if (!res.ok) throw new Error(`9router /models: ${res.status}`);
157
+ const raw = (await res.json()) as RouterModelsResponse;
158
+ const list = (raw.data ?? []).filter((m) => Boolean(m.id));
159
+ const fresh = await fetchModelsDevIndex().catch(() => new Map<string, ModelsDevModel>());
160
+ return list.map(toModelConfig(fresh));
161
+ },
162
+ };
120
163
  }
@@ -36,13 +36,12 @@ const config = {
36
36
  meta: (details: { _type: "fetchResult"; chars: number }) => `${details.chars} chars`,
37
37
  };
38
38
 
39
- test("renderCall shows title + muted arg", () => {
39
+ test("renderCall shows title + dim arg", () => {
40
40
  const stub = stubComponent();
41
41
  const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
42
42
  rc({ url: "https://x.com" }, theme, context(stub));
43
- // Only the tool name is accent/toolTitle-colored; the arg is muted so the
44
- // header doesn't render as one solid blue line (accent === toolTitle).
45
- expect(stub.get()).toBe("[toolTitle]*fetch* [muted]https://x.com");
43
+ // Only the tool name is accent/toolTitle-colored; the arg is secondary.
44
+ expect(stub.get()).toBe("[toolTitle]*fetch* [dim]https://x.com");
46
45
  });
47
46
 
48
47
  test("renderCall hides only an effectively collapsed call row", () => {
@@ -56,7 +55,7 @@ test("renderCall hides only an effectively collapsed call row", () => {
56
55
  theme,
57
56
  context(stub, { state: { collapsed: true }, expanded: true }),
58
57
  );
59
- expect(stub.get()).toBe("[toolTitle]*fetch* [muted]https://x.com");
58
+ expect(stub.get()).toBe("[toolTitle]*fetch* [dim]https://x.com");
60
59
  });
61
60
 
62
61
  test("renderResult renders structured compact details after collapse", () => {
@@ -157,7 +156,7 @@ test("renderResult renders empty and errors without compacting", () => {
157
156
  theme,
158
157
  context(empty),
159
158
  );
160
- expect(empty.get()).toBe(" [dim](empty)");
159
+ expect(empty.get()).toBe(" [muted](empty)");
161
160
 
162
161
  const failed = stubComponent();
163
162
  rr(
package/src/render.ts CHANGED
@@ -81,12 +81,12 @@ function dimBody(body: string, theme: Theme, expanded: boolean): string {
81
81
  const out = shown.map((line) => ` ${theme.fg("dim", line)}`);
82
82
  const remaining = lines.length - maxShow;
83
83
  if (remaining > 0) {
84
- out.push(` ${theme.fg("dim", `… ${remaining} more lines`)}`);
84
+ out.push(` ${theme.fg("muted", `… ${remaining} more lines`)}`);
85
85
  }
86
86
  return out.join("\n");
87
87
  }
88
88
 
89
- /** Build a `renderCall` that shows `<title> <accent arg>`. */
89
+ /** Build a `renderCall` that shows `<title> <secondary arg>`. */
90
90
  export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
91
91
  return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
92
92
  const text = getText(ctx.lastComponent);
@@ -97,7 +97,7 @@ export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => s
97
97
  )
98
98
  return text;
99
99
  const arg = pickArg(args);
100
- text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("muted", arg)}`);
100
+ text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("dim", arg)}`);
101
101
  return text;
102
102
  };
103
103
  }
@@ -118,7 +118,7 @@ export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetai
118
118
  return text;
119
119
  }
120
120
  if (!body.trim()) {
121
- text.setText(` ${theme.fg("dim", "(empty)")}`);
121
+ text.setText(` ${theme.fg("muted", "(empty)")}`);
122
122
  return text;
123
123
  }
124
124