@haoyiyin/9router 0.1.4 → 0.1.6

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 from the router, so their reasoning is decided by `comboReasoning` in `~/.pi/agent/haoyiyin/9router.json`. It defaults to **on** (assumes combo routes hit thinking-capable upstreams) with the standard `off..high` range. Opt out per machine with:
22
+
23
+ ```json
24
+ {
25
+ "comboReasoning": false
26
+ }
27
+ ```
28
+
29
+ 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.6",
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,14 @@ export const DEFAULT_BASE = "";
7
7
  export interface RouterConfig {
8
8
  baseUrl: string;
9
9
  apiKey: string;
10
+ /**
11
+ * Force reasoning:true onto combo models (`owned_by === "combo"`).
12
+ * Combo models carry no capabilities from the router, so this switch decides
13
+ * their reasoning status. Defaults to `true` (combo routes are assumed to hit
14
+ * thinking-capable upstreams); set `false` to opt out, or pass a custom
15
+ * `thinkingLevelMap` to unlock max/xhigh on the combo routes.
16
+ */
17
+ comboReasoning?: boolean | { thinkingLevelMap?: Record<string, string | null> };
10
18
  }
11
19
 
12
20
  export function configPath(): string {
@@ -17,6 +25,7 @@ export function loadConfig(): RouterConfig {
17
25
  const path = configPath();
18
26
  let fileBase: string | undefined;
19
27
  let fileKey: string | undefined;
28
+ let comboReasoning: RouterConfig["comboReasoning"] | undefined;
20
29
 
21
30
  if (existsSync(path)) {
22
31
  try {
@@ -24,6 +33,12 @@ export function loadConfig(): RouterConfig {
24
33
  if (parsed && typeof parsed === "object") {
25
34
  if (typeof parsed.baseUrl === "string") fileBase = parsed.baseUrl;
26
35
  if (typeof parsed.apiKey === "string") fileKey = parsed.apiKey;
36
+ if (
37
+ typeof parsed.comboReasoning === "boolean" ||
38
+ (parsed.comboReasoning && typeof parsed.comboReasoning === "object")
39
+ ) {
40
+ comboReasoning = parsed.comboReasoning;
41
+ }
27
42
  }
28
43
  } catch {
29
44
  // tolerate malformed config -> fall back to env/defaults
@@ -35,7 +50,7 @@ export function loadConfig(): RouterConfig {
35
50
 
36
51
  const apiKey = fileKey ?? process.env.ROUTER_API_KEY ?? "";
37
52
 
38
- return { baseUrl, apiKey };
53
+ return { baseUrl, apiKey, comboReasoning: comboReasoning ?? true };
39
54
  }
40
55
 
41
56
  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,65 @@ 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 expose off..max, hiding `off` when thinking cannot be disabled. No map
89
+ * 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
+ // openai-style: reasoning_effort takes the level name directly. Unlock
100
+ // xhigh/max, and hide off when thinking cannot be disabled.
101
+ return {
102
+ ...(cap.thinkingCanDisable === false ? { off: null } : {}),
103
+ xhigh: "xhigh",
104
+ max: "max",
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Combo models (`owned_by === "combo"`) carry no capabilities from the router, so
110
+ * their reasoning is decided by `comboReasoning` in 9router.json: default `true`
111
+ * (off..high range), `false` disables, or an object with a custom
112
+ * `thinkingLevelMap` to unlock max/xhigh.
113
+ */
114
+ export function resolveComboOptIn(
115
+ comboReasoning: RouterConfig["comboReasoning"],
116
+ ): { reasoning: true; thinkingLevelMap: ProviderModelConfig["thinkingLevelMap"] | undefined } | null {
117
+ if (!comboReasoning) return null;
118
+ return {
119
+ reasoning: true,
120
+ thinkingLevelMap:
121
+ comboReasoning === true
122
+ ? undefined
123
+ : (comboReasoning.thinkingLevelMap as ProviderModelConfig["thinkingLevelMap"]),
124
+ };
125
+ }
126
+
72
127
  /**
73
128
  * True if `err` is pi's stale-extension-ctx error, thrown when a captured
74
129
  * `pi`/command ctx is used after session replacement (newSession / fork /
@@ -118,15 +173,25 @@ export async function registerProvider(pi: ExtensionAPI): Promise<void> {
118
173
  if (!id || seen.has(id)) continue;
119
174
  seen.add(id);
120
175
  const devModel = lookupInIndex(id, devIndex);
176
+ const thinkingFormat = getThinkingFormat(model);
177
+ const comboOptIn = model.owned_by === "combo" ? resolveComboOptIn(cfg.comboReasoning) : null;
121
178
  out.push({
122
179
  id,
123
180
  name: getModelName(model, devModel),
124
- reasoning: getReasoning(model, devModel),
181
+ reasoning: comboOptIn ? comboOptIn.reasoning : getReasoning(model, devModel),
125
182
  input: getInputTypes(model, devModel),
126
183
  cost: ZERO_COST,
127
184
  contextWindow: getContextWindow(model, devModel),
128
185
  maxTokens: getMaxTokens(model, devModel),
129
- compat: COMPAT,
186
+ thinkingLevelMap: comboOptIn ? comboOptIn.thinkingLevelMap : getThinkingLevelMap(model),
187
+ compat: {
188
+ ...COMPAT,
189
+ supportsReasoningEffort: true,
190
+ thinkingFormat,
191
+ ...(thinkingFormat === "deepseek"
192
+ ? { requiresReasoningContentOnAssistantMessages: true }
193
+ : {}),
194
+ },
130
195
  });
131
196
  }
132
197