@haoyiyin/9router 0.1.3 → 0.1.5

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
@@ -16,6 +16,24 @@ There is no built-in default endpoint. Run `/9router`, enter the 9Router base UR
16
16
 
17
17
  Environment variables are supported as a fallback: `ROUTER_API_BASE` / `ROUTER_API_KEY` (the config file takes precedence).
18
18
 
19
+ ## Combo models & thinking
20
+
21
+ Combo models (`sub` / `free` / `main`, `owned_by: "combo"`) advertise no capabilities, so reasoning is off by default. If every upstream in a combo route supports thinking, opt in via `comboReasoning` in `~/.pi/agent/haoyiyin/9router.json`:
22
+
23
+ ```json
24
+ {
25
+ "comboReasoning": true
26
+ }
27
+ ```
28
+
29
+ `true` enables the default `off..high` range. To unlock `max`/`xhigh`, pass a custom `thinkingLevelMap` (keys: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; values: what gets sent to the router, `null` hides the level):
30
+
31
+ ```json
32
+ {
33
+ "comboReasoning": { "thinkingLevelMap": { "max": "max", "xhigh": "xhigh" } }
34
+ }
35
+ ```
36
+
19
37
  ## oc models
20
38
 
21
39
  Every 30 minutes the extension fetches models from `opencode.ai/zen/v1/models` whose ids end in `-free`, and registers them under the 9router provider as `oc/<id>`. Changes to the upstream list are picked up automatically. Cache file: `~/.cache/pi/zen-oc-models.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haoyiyin/9router",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "description": "Pi extension — 9Router provider (OpenCode Free oc/-free models + router models) + fetch/search/transcribe tools",
package/src/command.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { loadConfig, saveConfig, DEFAULT_BASE } from "./config.ts";
3
- import { registerProvider } from "./provider.ts";
3
+ import { registerProvider, isStaleCtxError } from "./provider.ts";
4
4
 
5
5
  // Never contains a real key. If a dialog pre-fills this placeholder as an
6
6
  // editable default, an exact match is treated as "keep the current key".
@@ -41,7 +41,9 @@ export function registerCommand(pi: ExtensionAPI): void {
41
41
  await registerProvider(pi);
42
42
  } catch (err) {
43
43
  ctx.ui.notify(
44
- `9Router saved, but provider re-registration failed: ${err instanceof Error ? err.message : String(err)}`,
44
+ isStaleCtxError(err)
45
+ ? "9Router saved. This extension instance was replaced by a session change/reload; the new instance has re-registered the provider."
46
+ : `9Router saved, but provider re-registration failed: ${err instanceof Error ? err.message : String(err)}`,
45
47
  "warning",
46
48
  );
47
49
  }
package/src/config.ts CHANGED
@@ -7,6 +7,13 @@ export const DEFAULT_BASE = "";
7
7
  export interface RouterConfig {
8
8
  baseUrl: string;
9
9
  apiKey: string;
10
+ /**
11
+ * Opt-in to force reasoning:true onto combo models (`owned_by === "combo"`).
12
+ * Combo models carry no capabilities from the router, so reasoning is normally
13
+ * off. Set `true` for the default off..high range, or pass a custom
14
+ * `thinkingLevelMap` to unlock max/xhigh on the combo routes.
15
+ */
16
+ comboReasoning?: boolean | { thinkingLevelMap?: Record<string, string | null> };
10
17
  }
11
18
 
12
19
  export function configPath(): string {
@@ -17,6 +24,7 @@ export function loadConfig(): RouterConfig {
17
24
  const path = configPath();
18
25
  let fileBase: string | undefined;
19
26
  let fileKey: string | undefined;
27
+ let comboReasoning: RouterConfig["comboReasoning"] | undefined;
20
28
 
21
29
  if (existsSync(path)) {
22
30
  try {
@@ -24,6 +32,12 @@ export function loadConfig(): RouterConfig {
24
32
  if (parsed && typeof parsed === "object") {
25
33
  if (typeof parsed.baseUrl === "string") fileBase = parsed.baseUrl;
26
34
  if (typeof parsed.apiKey === "string") fileKey = parsed.apiKey;
35
+ if (
36
+ typeof parsed.comboReasoning === "boolean" ||
37
+ (parsed.comboReasoning && typeof parsed.comboReasoning === "object")
38
+ ) {
39
+ comboReasoning = parsed.comboReasoning;
40
+ }
27
41
  }
28
42
  } catch {
29
43
  // tolerate malformed config -> fall back to env/defaults
@@ -35,7 +49,7 @@ export function loadConfig(): RouterConfig {
35
49
 
36
50
  const apiKey = fileKey ?? process.env.ROUTER_API_KEY ?? "";
37
51
 
38
- return { baseUrl, apiKey };
52
+ return { baseUrl, apiKey, comboReasoning };
39
53
  }
40
54
 
41
55
  export function saveConfig(cfg: RouterConfig): void {
package/src/data.ts CHANGED
@@ -44,6 +44,10 @@ export interface RouterModel {
44
44
  capabilities?: {
45
45
  contextWindow?: number;
46
46
  maxOutput?: number;
47
+ reasoning?: boolean;
48
+ thinkingFormat?: string;
49
+ thinkingCanDisable?: boolean;
50
+ thinkingRange?: unknown;
47
51
  [key: string]: unknown;
48
52
  };
49
53
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import registerFetch from "./fetch.ts";
3
3
  import registerSearch from "./search.ts";
4
4
  import registerTranscribe from "./transcribe.ts";
5
- import { registerProvider } from "./provider.ts";
5
+ import { registerProvider, isStaleCtxError } from "./provider.ts";
6
6
  import { registerCommand } from "./command.ts";
7
7
 
8
8
  export default async function (pi: ExtensionAPI): Promise<void> {
@@ -15,14 +15,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
15
15
  await registerProvider(pi);
16
16
 
17
17
  let refreshing = false;
18
+ let closed = false;
18
19
  const timer = setInterval(
19
20
  async () => {
20
21
  // ponytail: models refresh by unregister+register; zen cache TTL is
21
22
  // 30m so refetch happens naturally. Overlap guard avoids re-entrancy.
22
- if (refreshing) return;
23
+ if (closed || refreshing) return;
23
24
  refreshing = true;
24
25
  try {
25
26
  await registerProvider(pi);
27
+ } catch (err) {
28
+ // The captured `pi` goes stale after session replacement or
29
+ // extension reload, and pi's assertActive() throw must never
30
+ // escape this callback — it would crash the whole process as an
31
+ // uncaughtException. The new session's extension instance
32
+ // re-registers the provider itself, so stop this timer for good.
33
+ if (isStaleCtxError(err)) {
34
+ closed = true;
35
+ clearInterval(timer);
36
+ }
37
+ // Any other error (network hiccup, bad upstream payload) is
38
+ // swallowed: the next tick retries.
26
39
  } finally {
27
40
  refreshing = false;
28
41
  }
@@ -32,4 +45,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
32
45
  if (typeof (timer as { unref?: () => void }).unref === "function") {
33
46
  (timer as { unref: () => void }).unref();
34
47
  }
48
+ pi.on("session_shutdown", () => {
49
+ closed = true;
50
+ clearInterval(timer);
51
+ });
35
52
  }
package/src/provider.ts CHANGED
@@ -13,7 +13,7 @@ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-codin
13
13
  import type { ModelsDevModel, RouterModel } from "./data.ts";
14
14
  import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.ts";
15
15
  import { getZenFreeModels } from "./zen.ts";
16
- import { loadConfig } from "./config.ts";
16
+ import { loadConfig, type RouterConfig } from "./config.ts";
17
17
 
18
18
  const DEFAULT_CONTEXT_WINDOW = 128_000;
19
19
  const DEFAULT_MAX_TOKENS = 16_384;
@@ -65,10 +65,71 @@ export function getMaxTokens(model: RouterModel, devModel?: ModelsDevModel): num
65
65
  }
66
66
 
67
67
  export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boolean {
68
+ if (typeof model.capabilities?.reasoning === "boolean") return model.capabilities.reasoning;
68
69
  if (typeof devModel?.reasoning === "boolean") return devModel.reasoning;
69
70
  return /reasoner|thinking|xhigh|high|max|pro|codex|opus|sonnet/i.test(model.id ?? "");
70
71
  }
71
72
 
73
+ // pi understands these thinkingFormats; the router backend declares openai/deepseek
74
+ // (and occasionally minimax/gemini-level, which pi cannot natively drive). Map the
75
+ // known ones and fall back to openai for anything unknown/minimax/gemini-level.
76
+ const PI_THINKING_FORMAT: Record<string, "openai" | "deepseek"> = {
77
+ openai: "openai",
78
+ deepseek: "deepseek",
79
+ };
80
+
81
+ function getThinkingFormat(model: RouterModel): "openai" | "deepseek" {
82
+ return PI_THINKING_FORMAT[model.capabilities?.thinkingFormat ?? ""] ?? "openai";
83
+ }
84
+
85
+ /**
86
+ * Derive pi's thinkingLevelMap from the router's advertised capabilities.
87
+ * DeepSeek exposes low/high/max (mirrors pi's own deepseek catalog); openai-style
88
+ * models keep the default off..high range, only hiding `off` when thinking cannot
89
+ * be disabled. No map is returned when the backend does not declare reasoning.
90
+ */
91
+ export function getThinkingLevelMap(
92
+ model: RouterModel,
93
+ ): ProviderModelConfig["thinkingLevelMap"] | undefined {
94
+ const cap = model.capabilities;
95
+ if (cap?.reasoning !== true) return undefined;
96
+ if (cap.thinkingFormat === "deepseek") {
97
+ return { minimal: null, low: "low", medium: null, high: "high", max: "max" };
98
+ }
99
+ if (cap.thinkingCanDisable === false) return { off: null };
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * Combo models (`owned_by === "combo"`) carry no capabilities from the router, so
105
+ * reasoning is normally off. Operators who guarantee their combo routes only hit
106
+ * thinking-capable upstreams can opt in via `comboReasoning` in 9router.json:
107
+ * `true` for the default off..high range, or an object with a custom
108
+ * `thinkingLevelMap` to unlock max/xhigh.
109
+ */
110
+ export function resolveComboOptIn(
111
+ comboReasoning: RouterConfig["comboReasoning"],
112
+ ): { reasoning: true; thinkingLevelMap: ProviderModelConfig["thinkingLevelMap"] | undefined } | null {
113
+ if (!comboReasoning) return null;
114
+ return {
115
+ reasoning: true,
116
+ thinkingLevelMap:
117
+ comboReasoning === true
118
+ ? undefined
119
+ : (comboReasoning.thinkingLevelMap as ProviderModelConfig["thinkingLevelMap"]),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * True if `err` is pi's stale-extension-ctx error, thrown when a captured
125
+ * `pi`/command ctx is used after session replacement (newSession / fork /
126
+ * switchSession) or extension reload. A stale instance can never recover —
127
+ * the new session's extension instance re-registers everything itself.
128
+ */
129
+ export function isStaleCtxError(err: unknown): boolean {
130
+ return err instanceof Error && /stale after session replacement|ctx is stale/.test(err.message);
131
+ }
132
+
72
133
  export async function registerProvider(pi: ExtensionAPI): Promise<void> {
73
134
  const cfg = loadConfig();
74
135
 
@@ -108,15 +169,25 @@ export async function registerProvider(pi: ExtensionAPI): Promise<void> {
108
169
  if (!id || seen.has(id)) continue;
109
170
  seen.add(id);
110
171
  const devModel = lookupInIndex(id, devIndex);
172
+ const thinkingFormat = getThinkingFormat(model);
173
+ const comboOptIn = model.owned_by === "combo" ? resolveComboOptIn(cfg.comboReasoning) : null;
111
174
  out.push({
112
175
  id,
113
176
  name: getModelName(model, devModel),
114
- reasoning: getReasoning(model, devModel),
177
+ reasoning: comboOptIn ? comboOptIn.reasoning : getReasoning(model, devModel),
115
178
  input: getInputTypes(model, devModel),
116
179
  cost: ZERO_COST,
117
180
  contextWindow: getContextWindow(model, devModel),
118
181
  maxTokens: getMaxTokens(model, devModel),
119
- compat: COMPAT,
182
+ thinkingLevelMap: comboOptIn ? comboOptIn.thinkingLevelMap : getThinkingLevelMap(model),
183
+ compat: {
184
+ ...COMPAT,
185
+ supportsReasoningEffort: true,
186
+ thinkingFormat,
187
+ ...(thinkingFormat === "deepseek"
188
+ ? { requiresReasoningContentOnAssistantMessages: true }
189
+ : {}),
190
+ },
120
191
  });
121
192
  }
122
193