@haoyiyin/9router 0.1.7 → 0.3.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/README.md +1 -1
- package/package.json +1 -1
- package/src/data.ts +4 -0
- package/src/index.ts +0 -3
- package/src/provider.ts +90 -29
- package/src/command.ts +0 -54
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ pi install npm:@haoyiyin/9router
|
|
|
12
12
|
|
|
13
13
|
## Configure
|
|
14
14
|
|
|
15
|
-
There is no built-in default endpoint. Run `/
|
|
15
|
+
There is no built-in default endpoint. Run `/login`, select **9Router**, and enter the base URL and API key. They are saved to `~/.pi/agent/haoyiyin/9router.json` (mode `0600`). `/logout` removes the stored credential.
|
|
16
16
|
|
|
17
17
|
Environment variables are supported as a fallback: `ROUTER_API_BASE` / `ROUTER_API_KEY` (the config file takes precedence).
|
|
18
18
|
|
package/package.json
CHANGED
package/src/data.ts
CHANGED
|
@@ -37,13 +37,17 @@ export interface RouterModel {
|
|
|
37
37
|
name?: string;
|
|
38
38
|
context_window?: number;
|
|
39
39
|
contextWindow?: number;
|
|
40
|
+
/** 9router also advertises the OpenAI-style top-level aliases */
|
|
41
|
+
context_length?: number;
|
|
40
42
|
max_tokens?: number;
|
|
41
43
|
maxTokens?: number;
|
|
44
|
+
max_completion_tokens?: number;
|
|
42
45
|
owned_by?: string;
|
|
43
46
|
/** 9router v1 API nests these inside capabilities */
|
|
44
47
|
capabilities?: {
|
|
45
48
|
contextWindow?: number;
|
|
46
49
|
maxOutput?: number;
|
|
50
|
+
max_output?: number;
|
|
47
51
|
reasoning?: boolean;
|
|
48
52
|
thinkingFormat?: string;
|
|
49
53
|
thinkingCanDisable?: boolean;
|
package/src/index.ts
CHANGED
|
@@ -3,15 +3,12 @@ import registerFetch from "./fetch.ts";
|
|
|
3
3
|
import registerSearch from "./search.ts";
|
|
4
4
|
import registerTranscribe from "./transcribe.ts";
|
|
5
5
|
import { registerProvider, isStaleCtxError } from "./provider.ts";
|
|
6
|
-
import { registerCommand } from "./command.ts";
|
|
7
6
|
|
|
8
7
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
9
8
|
registerFetch(pi);
|
|
10
9
|
registerSearch(pi);
|
|
11
10
|
registerTranscribe(pi);
|
|
12
11
|
|
|
13
|
-
registerCommand(pi);
|
|
14
|
-
|
|
15
12
|
await registerProvider(pi);
|
|
16
13
|
|
|
17
14
|
let refreshing = false;
|
package/src/provider.ts
CHANGED
|
@@ -3,25 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers the "9router" provider in Pi, pulling the live router model list
|
|
5
5
|
* plus OpenCode's free oc/-free models. Falls back to an empty model list if
|
|
6
|
-
* the API key is unset (so
|
|
6
|
+
* the API key is unset (so /login can configure it and re-register once a key
|
|
7
7
|
* is saved).
|
|
8
8
|
*
|
|
9
9
|
* Config (base URL + key, with env fallback) is owned by ./config.ts.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
|
|
13
14
|
import type { ModelsDevModel, RouterModel } from "./data.ts";
|
|
14
15
|
import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.ts";
|
|
15
16
|
import { getZenFreeModels } from "./zen.ts";
|
|
16
|
-
import { loadConfig, type RouterConfig } from "./config.ts";
|
|
17
|
+
import { loadConfig, saveConfig, type RouterConfig } from "./config.ts";
|
|
17
18
|
|
|
18
|
-
// ponytail:
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
// ponytail: 16k was the old generic fallback; combo members bottom out at 128k,
|
|
23
|
-
// so the default now matches the smallest combo upstream max output.
|
|
24
|
-
const DEFAULT_MAX_TOKENS = 128_000;
|
|
19
|
+
// ponytail: pi's ProviderModelConfig requires both numbers, so a model the
|
|
20
|
+
// router leaves blank still needs a value. Used only when nothing is advertised.
|
|
21
|
+
const FALLBACK_CONTEXT_WINDOW = 128_000;
|
|
22
|
+
const FALLBACK_MAX_TOKENS = 16_384;
|
|
25
23
|
|
|
26
24
|
const ZERO_COST = {
|
|
27
25
|
input: 0,
|
|
@@ -49,23 +47,47 @@ export function getModelName(model: RouterModel, devModel?: ModelsDevModel): str
|
|
|
49
47
|
return model.name || devModel?.name || model.id || "unknown";
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
/** First positive finite number. 0/null/NaN are "not advertised", not a real limit. */
|
|
51
|
+
function firstPositive(...values: unknown[]): number | undefined {
|
|
52
|
+
for (const v of values) {
|
|
53
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Context window straight from the router. `fallback` applies only when the
|
|
60
|
+
* router (and models.dev) advertise nothing — never ahead of an API value.
|
|
61
|
+
*/
|
|
62
|
+
export function getContextWindow(
|
|
63
|
+
model: RouterModel,
|
|
64
|
+
devModel?: ModelsDevModel,
|
|
65
|
+
fallback?: number,
|
|
66
|
+
): number | undefined {
|
|
67
|
+
return firstPositive(
|
|
68
|
+
model.context_window,
|
|
69
|
+
model.contextWindow,
|
|
70
|
+
model.context_length,
|
|
71
|
+
model.capabilities?.contextWindow,
|
|
72
|
+
devModel?.limit?.context,
|
|
73
|
+
fallback,
|
|
59
74
|
);
|
|
60
75
|
}
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
/** Max output straight from the router. `fallback` only when nothing is advertised. */
|
|
78
|
+
export function getMaxTokens(
|
|
79
|
+
model: RouterModel,
|
|
80
|
+
devModel?: ModelsDevModel,
|
|
81
|
+
fallback?: number,
|
|
82
|
+
): number | undefined {
|
|
83
|
+
return firstPositive(
|
|
84
|
+
model.max_tokens,
|
|
85
|
+
model.maxTokens,
|
|
86
|
+
model.max_completion_tokens,
|
|
87
|
+
model.capabilities?.maxOutput,
|
|
88
|
+
model.capabilities?.max_output,
|
|
89
|
+
devModel?.limit?.output,
|
|
90
|
+
fallback,
|
|
69
91
|
);
|
|
70
92
|
}
|
|
71
93
|
|
|
@@ -139,17 +161,56 @@ export function isStaleCtxError(err: unknown): boolean {
|
|
|
139
161
|
return err instanceof Error && /stale after session replacement|ctx is stale/.test(err.message);
|
|
140
162
|
}
|
|
141
163
|
|
|
164
|
+
// Login flow: collect base URL + API key, persist to config, then re-register
|
|
165
|
+
// so the model list and base URL take effect. The key is carried as the OAuth
|
|
166
|
+
// credential (getApiKey); no separate apiKey is set on the provider, so /login
|
|
167
|
+
// shows exactly one 9router entry instead of an api_key + oauth pair.
|
|
168
|
+
function makeOAuth(pi: ExtensionAPI) {
|
|
169
|
+
return {
|
|
170
|
+
name: "9Router",
|
|
171
|
+
login: async (callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> => {
|
|
172
|
+
const current = loadConfig();
|
|
173
|
+
const baseUrl = await callbacks.onPrompt({
|
|
174
|
+
message: "9Router base URL",
|
|
175
|
+
placeholder: current.baseUrl || "https://your-router.example.com",
|
|
176
|
+
});
|
|
177
|
+
if (baseUrl === undefined) throw new Error("9Router login cancelled");
|
|
178
|
+
const trimmedBase = baseUrl.trim();
|
|
179
|
+
const normalizedBase = trimmedBase.endsWith("/")
|
|
180
|
+
? trimmedBase.slice(0, -1)
|
|
181
|
+
: trimmedBase;
|
|
182
|
+
|
|
183
|
+
const apiKey = await callbacks.onPrompt({
|
|
184
|
+
message: "9Router API key",
|
|
185
|
+
placeholder: "sk-...",
|
|
186
|
+
});
|
|
187
|
+
if (apiKey === undefined) throw new Error("9Router login cancelled");
|
|
188
|
+
const trimmedKey = apiKey.trim();
|
|
189
|
+
if (!trimmedKey) throw new Error("9Router API key is required");
|
|
190
|
+
|
|
191
|
+
saveConfig({ baseUrl: normalizedBase, apiKey: trimmedKey, comboReasoning: current.comboReasoning });
|
|
192
|
+
|
|
193
|
+
// Re-register with the freshly saved config so models + base URL apply.
|
|
194
|
+
await registerProvider(pi);
|
|
195
|
+
|
|
196
|
+
return { refresh: trimmedKey, access: trimmedKey, expires: Number.MAX_SAFE_INTEGER };
|
|
197
|
+
},
|
|
198
|
+
refreshToken: async (cred: OAuthCredentials): Promise<OAuthCredentials> => cred,
|
|
199
|
+
getApiKey: (cred: OAuthCredentials): string => cred.access,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
142
203
|
export async function registerProvider(pi: ExtensionAPI): Promise<void> {
|
|
143
204
|
const cfg = loadConfig();
|
|
205
|
+
const oauth = makeOAuth(pi);
|
|
144
206
|
|
|
145
207
|
if (!cfg.apiKey) {
|
|
146
|
-
// Register shell provider so
|
|
208
|
+
// Register shell provider so /login can configure it; no models yet.
|
|
147
209
|
pi.registerProvider("9router", {
|
|
148
210
|
name: "9Router",
|
|
149
|
-
baseUrl: cfg.baseUrl,
|
|
150
|
-
apiKey: "",
|
|
151
211
|
api: "openai-completions",
|
|
152
212
|
models: [],
|
|
213
|
+
oauth,
|
|
153
214
|
});
|
|
154
215
|
return;
|
|
155
216
|
}
|
|
@@ -186,8 +247,8 @@ export async function registerProvider(pi: ExtensionAPI): Promise<void> {
|
|
|
186
247
|
reasoning: comboOptIn ? comboOptIn.reasoning : getReasoning(model, devModel),
|
|
187
248
|
input: getInputTypes(model, devModel),
|
|
188
249
|
cost: ZERO_COST,
|
|
189
|
-
contextWindow: getContextWindow(model, devModel)
|
|
190
|
-
maxTokens: getMaxTokens(model, devModel)
|
|
250
|
+
contextWindow: getContextWindow(model, devModel, FALLBACK_CONTEXT_WINDOW)!,
|
|
251
|
+
maxTokens: getMaxTokens(model, devModel, FALLBACK_MAX_TOKENS)!,
|
|
191
252
|
thinkingLevelMap: comboOptIn ? comboOptIn.thinkingLevelMap : getThinkingLevelMap(model),
|
|
192
253
|
compat: {
|
|
193
254
|
...COMPAT,
|
|
@@ -228,10 +289,10 @@ export async function registerProvider(pi: ExtensionAPI): Promise<void> {
|
|
|
228
289
|
pi.registerProvider("9router", {
|
|
229
290
|
name: "9Router",
|
|
230
291
|
baseUrl: cfg.baseUrl,
|
|
231
|
-
apiKey: cfg.apiKey,
|
|
232
292
|
api: "openai-completions",
|
|
233
293
|
headers: { "User-Agent": "pi-coding-agent" },
|
|
234
294
|
models: out,
|
|
295
|
+
oauth,
|
|
235
296
|
});
|
|
236
297
|
}
|
|
237
298
|
|
package/src/command.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { loadConfig, saveConfig, DEFAULT_BASE } from "./config.ts";
|
|
3
|
-
import { registerProvider, isStaleCtxError } from "./provider.ts";
|
|
4
|
-
|
|
5
|
-
// Never contains a real key. If a dialog pre-fills this placeholder as an
|
|
6
|
-
// editable default, an exact match is treated as "keep the current key".
|
|
7
|
-
const API_KEY_PLACEHOLDER = "sk-... (leave empty to keep the current key)";
|
|
8
|
-
|
|
9
|
-
export function registerCommand(pi: ExtensionAPI): void {
|
|
10
|
-
pi.registerCommand("9router", {
|
|
11
|
-
description: "Configure 9Router base URL and API key (saved to ~/.pi/agent/haoyiyin/9router.json)",
|
|
12
|
-
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
13
|
-
const current = loadConfig();
|
|
14
|
-
|
|
15
|
-
const baseUrl = await ctx.ui.input("9Router base URL", current.baseUrl || DEFAULT_BASE);
|
|
16
|
-
if (baseUrl === undefined) return;
|
|
17
|
-
const trimmedBase = baseUrl.trim();
|
|
18
|
-
const normalizedBase = trimmedBase.endsWith("/")
|
|
19
|
-
? trimmedBase.slice(0, -1)
|
|
20
|
-
: trimmedBase;
|
|
21
|
-
|
|
22
|
-
const apiKey = await ctx.ui.input("9Router API key", API_KEY_PLACEHOLDER);
|
|
23
|
-
if (apiKey === undefined) return;
|
|
24
|
-
const trimmedKey = apiKey.trim();
|
|
25
|
-
|
|
26
|
-
let finalKey: string;
|
|
27
|
-
if (trimmedKey === "" || trimmedKey === API_KEY_PLACEHOLDER) {
|
|
28
|
-
// Keep the existing key; never treat the placeholder as a submitted value.
|
|
29
|
-
if (!current.apiKey) {
|
|
30
|
-
ctx.ui.notify("No API key configured", "error");
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
finalKey = current.apiKey;
|
|
34
|
-
} else {
|
|
35
|
-
finalKey = trimmedKey;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
saveConfig({ baseUrl: normalizedBase, apiKey: finalKey });
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
await registerProvider(pi);
|
|
42
|
-
} catch (err) {
|
|
43
|
-
ctx.ui.notify(
|
|
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)}`,
|
|
47
|
-
"warning",
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
ctx.ui.notify(`9Router saved: ${normalizedBase}`, "info");
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
}
|