@haoyiyin/9router 0.1.4 → 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.4",
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/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/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,61 @@ 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
+
72
123
  /**
73
124
  * True if `err` is pi's stale-extension-ctx error, thrown when a captured
74
125
  * `pi`/command ctx is used after session replacement (newSession / fork /
@@ -118,15 +169,25 @@ export async function registerProvider(pi: ExtensionAPI): Promise<void> {
118
169
  if (!id || seen.has(id)) continue;
119
170
  seen.add(id);
120
171
  const devModel = lookupInIndex(id, devIndex);
172
+ const thinkingFormat = getThinkingFormat(model);
173
+ const comboOptIn = model.owned_by === "combo" ? resolveComboOptIn(cfg.comboReasoning) : null;
121
174
  out.push({
122
175
  id,
123
176
  name: getModelName(model, devModel),
124
- reasoning: getReasoning(model, devModel),
177
+ reasoning: comboOptIn ? comboOptIn.reasoning : getReasoning(model, devModel),
125
178
  input: getInputTypes(model, devModel),
126
179
  cost: ZERO_COST,
127
180
  contextWindow: getContextWindow(model, devModel),
128
181
  maxTokens: getMaxTokens(model, devModel),
129
- 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
+ },
130
191
  });
131
192
  }
132
193