@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 +18 -0
- package/package.json +1 -1
- package/src/command.ts +4 -2
- package/src/config.ts +15 -1
- package/src/data.ts +4 -0
- package/src/index.ts +19 -2
- package/src/provider.ts +74 -3
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
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
|
-
|
|
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
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
|
-
|
|
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
|
|