pi-multikey 1.13.0 → 1.15.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 CHANGED
@@ -40,6 +40,8 @@ Built-in presets decouple "model settings" from "keys". The data comes from b.ai
40
40
  | hy3 | 256K / 128K | text | off · low · high |
41
41
  | mimo-v2.5 | 1M / 128K | text+image | off · high (official: low/medium/high behave identically) |
42
42
  | qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
43
+ | deepseek-v4.1-flash | 1M / 384K | text+image | off · low · high · max (official DeepSeek V4 tiers; b.ai probed live 2026-09-25) |
44
+ | glm-5.3-flash | 1M / 131K | text+image | low · high · max (no off — GLM always thinks; b.ai probed live 2026-09-25) |
43
45
 
44
46
  > Why `null` must be explicit: pi's `getSupportedThinkingLevels` treats `mapped === null` as unsupported and hides that level, but **omitting** it is treated as supported and the level name is sent to the API verbatim; `xhigh` / `max` additionally require an explicit non-null value to be usable.
45
47
 
package/README.zh.md CHANGED
@@ -41,6 +41,8 @@ DeepSeek / Tencent / 小米官方文档,并对每个 thinking 档位做过实
41
41
  | hy3 | 256K / 128K | text | off · low · high |
42
42
  | mimo-v2.5 | 1M / 128K | text+image | off · high(官方:low/medium/high 行为相同) |
43
43
  | qwen3.8-flash | 1M / 131K | text+image | off · low · medium · xhigh |
44
+ | deepseek-v4.1-flash | 1M / 384K | text+image | off · low · high · max(官方 DeepSeek V4 档位;b.ai 已于 2026-09-25 实测) |
45
+ | glm-5.3-flash | 1M / 131K | text+image | low · high · max(无 off——GLM 始终思考;b.ai 已于 2026-09-25 实测) |
44
46
 
45
47
  > 为什么必须显式写 `null`:pi 的 `getSupportedThinkingLevels` 把 `mapped === null`
46
48
  > 视为不支持并隐藏该档,但**省略**会被当作支持并把档名原样发给 API;
package/config.ts CHANGED
@@ -360,8 +360,8 @@ function normalizePool(pool: PoolConfig): PoolConfig {
360
360
  }
361
361
 
362
362
  /** Convert a pool config into pi ProviderModelConfig-style definitions (provider-level compat merged in). */
363
- export function toProviderModels(pool: PoolConfig): ProviderModelConfig[] {
364
- return pool.models.map(
363
+ export function toProviderModels(pool: PoolConfig, models?: PoolModelConfig[]): ProviderModelConfig[] {
364
+ return (models ?? pool.models).map(
365
365
  (m): ProviderModelConfig => ({
366
366
  id: m.id,
367
367
  name: m.name ?? m.id,
package/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
13
  import { getApiProvider, type Api } from "@earendil-works/pi-ai";
14
- import { configPath, endpointHeaders, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig } from "./config.ts";
14
+ import { configPath, endpointHeaders, loadConfig, saveConfig, toProviderModels, type KeypoolConfig, type PoolConfig, type PoolModelConfig } from "./config.ts";
15
15
  import { resetConversation } from "./identity.ts";
16
16
  import { KeyPool } from "./pool.ts";
17
17
  import { createRotatingStreamSimple } from "./stream.ts";
@@ -36,6 +36,8 @@ export default function multikey(pi: ExtensionAPI) {
36
36
 
37
37
  const pools = new Map<string, KeyPool>();
38
38
  for (const pool of config.pools) pools.set(pool.id, new KeyPool(pool));
39
+ /** pi provider ids registered per pool (pool.id plus one suffixed id per extra transport). */
40
+ const providerIds = new Map<string, string[]>();
39
41
 
40
42
  let ui: ExtensionContext["ui"] | undefined;
41
43
  const notify = (message: string) => {
@@ -47,33 +49,68 @@ export default function multikey(pi: ExtensionAPI) {
47
49
  };
48
50
 
49
51
  /**
50
- * Register a pool as a pi provider. Returns undefined on success, or a
52
+ * Register a pool as pi provider(s). Returns undefined on success, or a
51
53
  * human-readable reason why the pool was skipped (unknown api, incomplete).
54
+ *
55
+ * One pi provider per transport: pi dispatches to a provider's custom
56
+ * streamSimple only when model.api matches the registered api
57
+ * (provider-composer.js), so models on another transport would bypass key
58
+ * rotation and per-request identity headers entirely — on OpenCode Zen
59
+ * that draws 403 FreeTierError (verified live 2026-09-21: muse-spark went
60
+ * out with the dummy key and a uuid session). Extra transports register
61
+ * under "<pool.id>.<api>" sharing the same KeyPool, so keys are entered
62
+ * once and rotation/cooldowns span both providers.
52
63
  */
53
64
  function registerPool(pool: PoolConfig): string | undefined {
54
65
  if (pool.keys.length === 0 || pool.models.length === 0) {
55
66
  return pool.keys.length === 0 ? "no API keys" : "no models";
56
67
  }
57
- const api = pool.api ?? "openai-completions";
58
- if (!getApiProvider(api as Api)) {
59
- // Never throw at startup over a bad api value; report it instead so
60
- // the user gets a fix hint (and /multikey marks the pool broken).
61
- return `unknown api type "${api}" (edit the pool and pick a valid API type)`;
68
+ const defaultApi = pool.api ?? "openai-completions";
69
+ const groups = new Map<string, PoolModelConfig[]>();
70
+ for (const m of pool.models) {
71
+ const api = m.api ?? defaultApi;
72
+ const list = groups.get(api);
73
+ if (list) list.push(m);
74
+ else groups.set(api, [m]);
75
+ }
76
+ for (const api of groups.keys()) {
77
+ if (!getApiProvider(api as Api)) {
78
+ // Never throw at startup over a bad api value; report it instead so
79
+ // the user gets a fix hint (and /multikey marks the pool broken).
80
+ return `unknown api type "${api}" (edit the pool and pick a valid API type)`;
81
+ }
62
82
  }
63
83
  const keyPool = pools.get(pool.id) ?? new KeyPool(pool);
64
84
  keyPool.updateConfig(pool);
65
85
  pools.set(pool.id, keyPool);
66
86
 
67
- pi.registerProvider(pool.id, {
68
- name: pool.name ?? pool.id,
69
- baseUrl: pool.baseUrl,
70
- // Real keys are injected per-request by the rotating stream function.
71
- apiKey: "multikey-managed",
72
- api,
73
- headers: { ...pool.headers, ...endpointHeaders(pool.baseUrl) },
74
- models: toProviderModels(pool),
75
- streamSimple: createRotatingStreamSimple(keyPool, api, notify, () => saveConfig(config)),
76
- });
87
+ const wanted: string[] = [];
88
+ for (const [api, models] of groups) {
89
+ const providerId = api === defaultApi ? pool.id : `${pool.id}.${api}`;
90
+ wanted.push(providerId);
91
+ pi.registerProvider(providerId, {
92
+ name: api === defaultApi ? (pool.name ?? pool.id) : `${pool.name ?? pool.id} (${api})`,
93
+ baseUrl: pool.baseUrl,
94
+ // Real keys are injected per-request by the rotating stream function.
95
+ apiKey: "multikey-managed",
96
+ api,
97
+ headers: { ...pool.headers, ...endpointHeaders(pool.baseUrl) },
98
+ models: toProviderModels(pool, models),
99
+ streamSimple: createRotatingStreamSimple(keyPool, api, notify, () => saveConfig(config)),
100
+ });
101
+ }
102
+ // Drop split providers left over from a previous registration whose
103
+ // models have since moved transports (or back to a single provider).
104
+ for (const stale of providerIds.get(pool.id) ?? []) {
105
+ if (!wanted.includes(stale)) {
106
+ try {
107
+ pi.unregisterProvider(stale);
108
+ } catch {
109
+ // Already gone.
110
+ }
111
+ }
112
+ }
113
+ providerIds.set(pool.id, wanted);
77
114
  return undefined;
78
115
  }
79
116
 
@@ -98,11 +135,14 @@ export default function multikey(pi: ExtensionAPI) {
98
135
  config.pools = config.pools.filter((p) => p.id !== poolId);
99
136
  pools.delete(poolId);
100
137
  saveConfig(config);
101
- try {
102
- pi.unregisterProvider(poolId);
103
- } catch {
104
- // Not registered yet.
138
+ for (const providerId of [poolId, ...(providerIds.get(poolId) ?? [])]) {
139
+ try {
140
+ pi.unregisterProvider(providerId);
141
+ } catch {
142
+ // Not registered yet.
143
+ }
105
144
  }
145
+ providerIds.delete(poolId);
106
146
  }
107
147
 
108
148
  function reloadFromDisk() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multikey",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "One pi provider backed by many API keys: automatic 429 rotation, per-request key leases for concurrent subagents, and a /multikey management TUI",
5
5
  "keywords": [
6
6
  "pi-package",
package/presets.ts CHANGED
@@ -102,38 +102,48 @@ export const PRESETS: Preset[] = [
102
102
  // consensus (models.dev opencode/greenpt rows + the same model
103
103
  // behind cline-free/deepseek-v4.1-flash, verified live 2026-09-17):
104
104
  // ctx 1M, out 384K. Text+image input (models.dev opencode row).
105
- // Thinking tiers UNVERIFIED on b.ai: conservative off/high only —
106
- // b.ai rejects minimal/xhigh/max with HTTP 400 (seen on mimo-v2.5).
107
- // Widen the map after probing with a paid key.
105
+ // Thinking tiers probed live on b.ai 2026-09-25 (paid key, exact pi
106
+ // request shape: thinking.enabled + reasoning_effort): every level is
107
+ // accepted — the mimo-v2.5-style HTTP 400 for minimal/xhigh/max does
108
+ // NOT apply to this model. DeepSeek official exposes only
109
+ // low/high/max, and low vs high are behaviorally distinct in reasoning
110
+ // length, so minimal/medium/xhigh stay hidden (b.ai accepts them but
111
+ // treats them as duplicates).
108
112
  id: "deepseek-v4.1-flash",
109
113
  name: "DeepSeek V4.1 Flash",
110
114
  reasoning: true,
111
115
  input: ["text", "image"],
112
116
  contextWindow: 1_000_000,
113
117
  maxTokens: 384_000,
114
- thinkingLevelMap: levels({ off: "none", high: "high" }),
118
+ thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
115
119
  },
116
120
  {
117
121
  // b.ai /v1/models lists `glm-5.3-flash` (bare ids only — verified
118
122
  // live 2026-09-21). Sizes from catalog consensus (models.dev
119
123
  // zhipuai/zai rows): ctx 1M, out 128K. Upstream inputs
120
124
  // text/image/video/pdf (pi tracks text + image, like mimo-v2.5).
121
- // Thinking tiers UNVERIFIED on b.ai: conservative off/high only
122
- // (see deepseek-v4.1-flash above). Widen after probing.
125
+ // Thinking tiers probed live on b.ai 2026-09-25 (paid key, exact pi
126
+ // request shape): low/high/max accepted and behaviorally distinct
127
+ // (completion 153/257/434 tok on a fixed prompt). medium/minimal are
128
+ // REJECTED (HTTP 400), and thinking:{type:"disabled"} (what pi sends
129
+ // for off) also 400s — so off stays hidden: GLM always thinks, with
130
+ // the plain no-param request defaulting to deep reasoning (verified
131
+ // 200). xhigh is accepted with ≈max-like depth on a single sample but
132
+ // upstream GLM 5.3 Flash declares no xhigh tier — hidden for now.
123
133
  id: "glm-5.3-flash",
124
134
  name: "GLM 5.3 Flash",
125
135
  reasoning: true,
126
136
  input: ["text", "image"],
127
137
  contextWindow: 1_000_000,
128
138
  maxTokens: 131_072,
129
- thinkingLevelMap: levels({ off: "none", high: "high" }),
139
+ thinkingLevelMap: levels({ off: null, low: "low", high: "high", max: "max" }),
130
140
  },
131
141
  ],
132
142
  },
133
143
  {
134
144
  id: "opencode-zen",
135
145
  name: "OpenCode Zen",
136
- description: "opencode.ai/zen free tier — Big Pickle, MiMo V2.5, Ling 3.0 Fin, Nemotron 3 Ultra/Lightning, Muse Spark 1.3 (6 free models)",
146
+ description: "opencode.ai/zen free tier — Big Pickle, MiMo V2.6 Flash, MiMo V2.5, Ling 3.0 Fin, Nemotron 3 Ultra/Lightning, Muse Spark 1.3 (7 free models)",
137
147
  defaultPoolId: "zen",
138
148
  // Inference gateway, not zen/v1: the v2 client's console /api/v2/config
139
149
  // prescribes this as the opencode provider baseURL, and the free lineup
@@ -155,10 +165,39 @@ export const PRESETS: Preset[] = [
155
165
  maxTokens: 32_000,
156
166
  compat: ZEN_CHAT_COMPAT,
157
167
  },
168
+ {
169
+ // Xiaomi MiMo-V2.6-Flash, released to the Zen free tier 2026-09-22 and
170
+ // the successor of mimo-v2.5-free (docs opencode.ai/docs/zen lists both,
171
+ // in that order). Limits are authoritative from console /api/v2/config
172
+ // `providers.opencode.models` and mirrored exactly by models.dev:
173
+ // ctx 200,000 / out 32,000, tools on, cost 0. Upstream inputs
174
+ // text/image/audio/video/pdf (pi tracks text + image, like mimo-v2.5).
175
+ // Reasoning is always-on with no effort control: models.dev reports
176
+ // `reasoning: true`, `reasoning_options: []`, `interleaved.field =
177
+ // reasoning_content`; verified live 2026-09-22 that reasoning_content
178
+ // streams separately and usage reports reasoning_tokens — so, like the
179
+ // rest of the free lineup, no thinkingLevelMap.
180
+ // Verified live 2026-09-22 (agentic probe shape, see probe.ts): streaming
181
+ // chat with the read/shell/edit/write quartet → 200, content "PONG",
182
+ // finish stop, usage {prompt 191, completion 13, reasoning 9}; the same
183
+ // request without tools/stream → 403 FreeTierError, confirming the gate
184
+ // still applies to this model id.
185
+ id: "mimo-v2.6-flash-free",
186
+ name: "MiMo V2.6 Flash Free",
187
+ reasoning: true,
188
+ input: ["text", "image"],
189
+ contextWindow: 200_000,
190
+ maxTokens: 127_000,
191
+ compat: ZEN_CHAT_COMPAT,
192
+ },
158
193
  {
159
194
  // Xiaomi MiMo V2.5 omni; raw model is 1M ctx but the Zen FREE tier serves 200K/32K.
160
195
  // Repo metadata: inputs text/image/audio/video (pi tracks text + image),
161
196
  // reasoning via separate reasoning_content stream, no reasoning_options.
197
+ // Still listed in opencode.ai/docs/zen's free lineup and still answers 200
198
+ // (verified live 2026-09-22), but it disappeared from the console
199
+ // /api/v2/config model map that day when mimo-v2.6-flash-free landed —
200
+ // kept because it demonstrably works; drop it when it 404s.
162
201
  id: "mimo-v2.5-free",
163
202
  name: "MiMo V2.5 Free",
164
203
  reasoning: true,
@@ -247,6 +286,7 @@ export const PRESETS: Preset[] = [
247
286
  // (deepseek/deepseek-v4.1-flash) is usage-billed — see the comment above.
248
287
  // Catalog (openrouter): ctx 1048576, out 384000 (both verified live), text+image.
249
288
  // Effort tiers verified live via reasoning:{effort} (incl. "none" = reasoning off).
289
+ // DeepSeek official tiers are only low/high/max, so minimal/medium/xhigh stay hidden.
250
290
  id: "cline-free/deepseek-v4.1-flash",
251
291
  name: "DeepSeek V4.1 Flash (Free)",
252
292
  reasoning: true,
@@ -254,7 +294,7 @@ export const PRESETS: Preset[] = [
254
294
  contextWindow: 1_048_576,
255
295
  maxTokens: 384_000,
256
296
  compat: { thinkingFormat: "openrouter" },
257
- thinkingLevelMap: levels({ off: "none", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" }),
297
+ thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
258
298
  },
259
299
  {
260
300
  // Free at its raw id (no cline-free/ prefix in the feed); 262K window measured
package/stream.ts CHANGED
@@ -54,8 +54,8 @@ interface CapturedResponse {
54
54
  export type Notifier = (message: string) => void;
55
55
 
56
56
  export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notify: Notifier, onConfigDirty?: () => void) {
57
- const impl = getApiProvider(apiName as Api);
58
- if (!impl) throw new Error(`multikey: no API provider registered for api: ${apiName}`);
57
+ const fallback = getApiProvider(apiName as Api);
58
+ if (!fallback) throw new Error(`multikey: no API provider registered for api: ${apiName}`);
59
59
  // Auth style: "api-key" providers want the key in x-api-key (some reject
60
60
  // Authorization entirely); bearer is the pi-ai default and needs no help.
61
61
  const authStyle = pool.config.auth ?? "bearer";
@@ -108,6 +108,15 @@ export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notif
108
108
 
109
109
  try {
110
110
  const captured: CapturedResponse = { status: 0 };
111
+ // Transport follows the MODEL, not the pool: pi dispatches to a
112
+ // provider's custom streamSimple only when model.api matches the
113
+ // registered api (provider-composer.js), so a pool mixing
114
+ // transports (e.g. oc.zen's openai-responses muse-spark entry)
115
+ // must resolve the impl per request. Anything without a match
116
+ // falls back to the pool-level api. Verified live 2026-09-21:
117
+ // without this, muse-spark bypassed rotation (dummy key, no
118
+ // ses_/msg_ identity headers) and drew 403 FreeTierError.
119
+ const impl = getApiProvider(model.api) ?? fallback;
111
120
  // Identity headers (session / request) are per-request, so they are
112
121
  // merged here rather than baked into the provider registration.
113
122
  // Providers apply options.headers last, so these win over the static