pi-multikey 1.7.0 → 1.8.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 +68 -37
- package/README.zh.md +32 -15
- package/cline-auth.ts +242 -0
- package/config.ts +142 -13
- package/identity.ts +101 -0
- package/index.ts +34 -4
- package/manage.ts +289 -12
- package/package.json +3 -1
- package/pool.ts +49 -6
- package/presets.ts +114 -55
- package/probe.ts +8 -3
- package/stream.ts +98 -6
package/presets.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* live probes — see README "Presets" for the evidence trail.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
9
11
|
import type { PoolConfig, PoolModelConfig } from "./config.ts";
|
|
10
12
|
|
|
11
13
|
export interface Preset {
|
|
@@ -55,34 +57,13 @@ export const PRESETS: Preset[] = [
|
|
|
55
57
|
{
|
|
56
58
|
id: "b-ai",
|
|
57
59
|
name: "B.AI",
|
|
58
|
-
description: "api.b.ai —
|
|
60
|
+
description: "api.b.ai — Hunyuan Hy3, MiMo V2.5, Qwen3.8, GLM 5.3 (4 models)",
|
|
59
61
|
defaultPoolId: "bai",
|
|
60
62
|
baseUrl: "https://api.b.ai/v1",
|
|
61
63
|
api: "openai-completions",
|
|
62
64
|
compat: BAI_COMPAT,
|
|
63
65
|
keyHint: "https://www.b.ai/ → API Keys (one entry per key; multiple keys share the load)",
|
|
64
66
|
models: [
|
|
65
|
-
{
|
|
66
|
-
// docs.b.ai/llmservice/models/deepseek-v4-flash + api-docs.deepseek.com/guides/thinking_mode
|
|
67
|
-
// Native effort tiers: low/high/max (medium & xhigh alias to high server-side).
|
|
68
|
-
id: "deepseek-v4-flash",
|
|
69
|
-
name: "DeepSeek V4 Flash",
|
|
70
|
-
reasoning: true,
|
|
71
|
-
input: ["text"],
|
|
72
|
-
contextWindow: 1_000_000,
|
|
73
|
-
maxTokens: 384_000,
|
|
74
|
-
thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
// No public card; experimental vision build on the same V4 Flash backbone.
|
|
78
|
-
id: "deepseek-v4-flash-vision-exp",
|
|
79
|
-
name: "DeepSeek V4 Flash Vision (exp)",
|
|
80
|
-
reasoning: true,
|
|
81
|
-
input: ["text", "image"],
|
|
82
|
-
contextWindow: 1_000_000,
|
|
83
|
-
maxTokens: 384_000,
|
|
84
|
-
thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
|
|
85
|
-
},
|
|
86
67
|
{
|
|
87
68
|
// docs.b.ai/llmservice/models/hy3 — Tencent modes: no_think / think_low / think_high.
|
|
88
69
|
id: "hy3",
|
|
@@ -131,7 +112,7 @@ export const PRESETS: Preset[] = [
|
|
|
131
112
|
{
|
|
132
113
|
id: "opencode-zen",
|
|
133
114
|
name: "OpenCode Zen",
|
|
134
|
-
description: "opencode.ai/zen free tier — Big Pickle,
|
|
115
|
+
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)",
|
|
135
116
|
defaultPoolId: "zen",
|
|
136
117
|
baseUrl: "https://opencode.ai/zen/v1",
|
|
137
118
|
api: "openai-completions",
|
|
@@ -148,22 +129,10 @@ export const PRESETS: Preset[] = [
|
|
|
148
129
|
maxTokens: 32_000,
|
|
149
130
|
compat: ZEN_CHAT_COMPAT,
|
|
150
131
|
},
|
|
151
|
-
{
|
|
152
|
-
// DeepSeek V4 Flash on the Zen FREE tier — id confirmed via GET /zen/v1/models
|
|
153
|
-
// (deepseek-v4-flash-free); serving limits 200K/128K from models.dev `opencode`
|
|
154
|
-
// provider. Thinking levels mirror the b.ai preset's deepseek-v4-flash
|
|
155
|
-
// (off/low/high/max; medium & xhigh alias to high server-side).
|
|
156
|
-
id: "deepseek-v4-flash-free",
|
|
157
|
-
name: "DeepSeek V4 Flash Free",
|
|
158
|
-
reasoning: true,
|
|
159
|
-
input: ["text"],
|
|
160
|
-
contextWindow: 200_000,
|
|
161
|
-
maxTokens: 128_000,
|
|
162
|
-
thinkingLevelMap: levels({ off: "none", low: "low", high: "high", max: "max" }),
|
|
163
|
-
compat: ZEN_CHAT_COMPAT,
|
|
164
|
-
},
|
|
165
132
|
{
|
|
166
133
|
// Xiaomi MiMo V2.5 omni; raw model is 1M ctx but the Zen FREE tier serves 200K/32K.
|
|
134
|
+
// Repo metadata: inputs text/image/audio/video (pi tracks text + image),
|
|
135
|
+
// reasoning via separate reasoning_content stream, no reasoning_options.
|
|
167
136
|
id: "mimo-v2.5-free",
|
|
168
137
|
name: "MiMo V2.5 Free",
|
|
169
138
|
reasoning: true,
|
|
@@ -172,19 +141,6 @@ export const PRESETS: Preset[] = [
|
|
|
172
141
|
maxTokens: 32_000,
|
|
173
142
|
compat: ZEN_CHAT_COMPAT,
|
|
174
143
|
},
|
|
175
|
-
{
|
|
176
|
-
// Tencent Hy3; raw model is 262K ctx but the Zen FREE tier serves 190K/64K.
|
|
177
|
-
// thinkingFormat defaults to reasoning_effort: levels map straight to Hy3's
|
|
178
|
-
// low/medium/high effort tiers; no off toggle on the free endpoint.
|
|
179
|
-
id: "hy3-free",
|
|
180
|
-
name: "Hy3 Free",
|
|
181
|
-
reasoning: true,
|
|
182
|
-
input: ["text"],
|
|
183
|
-
contextWindow: 190_000,
|
|
184
|
-
maxTokens: 64_000,
|
|
185
|
-
thinkingLevelMap: levels({ low: "low", medium: "medium", high: "high" }),
|
|
186
|
-
compat: ZEN_CHAT_COMPAT,
|
|
187
|
-
},
|
|
188
144
|
{
|
|
189
145
|
// Finance-tuned Ling 3.0 Flash; reasoning toggle only (no effort tiers).
|
|
190
146
|
id: "ling-3.0-flash-fin-free",
|
|
@@ -216,26 +172,127 @@ export const PRESETS: Preset[] = [
|
|
|
216
172
|
compat: ZEN_CHAT_COMPAT,
|
|
217
173
|
},
|
|
218
174
|
{
|
|
219
|
-
// Meta Muse Spark 1.
|
|
220
|
-
// completions)
|
|
221
|
-
|
|
222
|
-
|
|
175
|
+
// Meta Muse Spark 1.3 Contributor Free — OpenAI Responses API endpoint (not chat
|
|
176
|
+
// completions). Repo metadata: no reasoning_options (always-on reasoning, no
|
|
177
|
+
// effort control), reasoning bundled into content (no separate stream field);
|
|
178
|
+
// inputs text/image/video/pdf/audio (pi tracks text + image).
|
|
179
|
+
// muse-spark-1.2-contributor-free was removed: legacy variant no longer in the
|
|
180
|
+
// free-model list at opencode.ai/docs/zen.
|
|
181
|
+
id: "muse-spark-1.3-contributor-free",
|
|
182
|
+
name: "Muse Spark 1.3 Contributor Free",
|
|
223
183
|
api: "openai-responses",
|
|
224
184
|
reasoning: true,
|
|
225
185
|
input: ["text", "image"],
|
|
226
186
|
contextWindow: 1_048_576,
|
|
227
187
|
maxTokens: 131_072,
|
|
228
|
-
thinkingLevelMap: levels({ minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: "xhigh" }),
|
|
229
188
|
compat: { sessionAffinityFormat: "openai-nosession" },
|
|
230
189
|
},
|
|
231
190
|
],
|
|
232
191
|
},
|
|
192
|
+
{
|
|
193
|
+
// Cline's free-model promotion: a Cline account (OAuth, no static API key)
|
|
194
|
+
// gets a daily per-model quota on api.cline.bot's OpenAI-compatible API.
|
|
195
|
+
// The lineup rotates — retired ids answer "model not found" — so the
|
|
196
|
+
// `_preset` sync machinery is the intended way to receive lineup updates.
|
|
197
|
+
// Context/output limits are best-effort (server-enforced); tune per model
|
|
198
|
+
// in multikey.json if a provider rejects long conversations.
|
|
199
|
+
id: "cline-free",
|
|
200
|
+
name: "Cline Free",
|
|
201
|
+
description:
|
|
202
|
+
"api.cline.bot — Cline account free tier: DeepSeek V4 Flash, Longcat 2.0, Laguna S 2.1, GLM 5.2 (daily per-model quota, lineup rotates)",
|
|
203
|
+
defaultPoolId: "cline",
|
|
204
|
+
baseUrl: "https://api.cline.bot/api/v1",
|
|
205
|
+
api: "openai-completions",
|
|
206
|
+
keyHint: "Cline account — use 'Sign in with Cline (device flow)', or paste an access token from ~/.cline/data/secrets.json",
|
|
207
|
+
models: [
|
|
208
|
+
{
|
|
209
|
+
id: "deepseek/deepseek-v4-flash",
|
|
210
|
+
name: "DeepSeek V4 Flash",
|
|
211
|
+
reasoning: true,
|
|
212
|
+
input: ["text"],
|
|
213
|
+
contextWindow: 1_000_000,
|
|
214
|
+
maxTokens: 131_072,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "meituan/longcat-2.0",
|
|
218
|
+
name: "Longcat 2.0",
|
|
219
|
+
reasoning: true,
|
|
220
|
+
input: ["text"],
|
|
221
|
+
contextWindow: 1_000_000,
|
|
222
|
+
maxTokens: 131_072,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "poolside/laguna-s-2.1:free",
|
|
226
|
+
name: "Laguna S 2.1 (Free)",
|
|
227
|
+
reasoning: true,
|
|
228
|
+
input: ["text"],
|
|
229
|
+
// Poolside doesn't publish the window; safe default, tune if needed.
|
|
230
|
+
contextWindow: 128_000,
|
|
231
|
+
maxTokens: 16_384,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: "z-ai/glm-5.2:free",
|
|
235
|
+
name: "GLM 5.2 (Free)",
|
|
236
|
+
reasoning: true,
|
|
237
|
+
input: ["text"],
|
|
238
|
+
contextWindow: 200_000,
|
|
239
|
+
maxTokens: 131_072,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
},
|
|
233
243
|
];
|
|
234
244
|
|
|
235
245
|
export function findPreset(id: string): Preset | undefined {
|
|
236
246
|
return PRESETS.find((p) => p.id === id);
|
|
237
247
|
}
|
|
238
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Stable fingerprint of a preset's model list (sha256, first 16 hex chars).
|
|
251
|
+
* Both sides of the comparison come from presets.ts builds, so JSON key order
|
|
252
|
+
* is deterministic. Covers models only — compat/api/description changes don't
|
|
253
|
+
* trigger update prompts.
|
|
254
|
+
*/
|
|
255
|
+
export function presetFingerprint(preset: Preset): string {
|
|
256
|
+
return createHash("sha256").update(JSON.stringify(preset.models)).digest("hex").slice(0, 16);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface PresetModelDiff {
|
|
260
|
+
/** Shipped by the preset but missing from the pool. */
|
|
261
|
+
added: PoolModelConfig[];
|
|
262
|
+
/** Still in the pool but no longer shipped by the preset. */
|
|
263
|
+
removed: PoolModelConfig[];
|
|
264
|
+
/** Same model id, different spec (per-field comparison, order-insensitive). */
|
|
265
|
+
changed: { id: string; fields: string[] }[];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Compare a pool's current models against the shipped preset models. */
|
|
269
|
+
export function diffPresetModels(poolModels: PoolModelConfig[], presetModels: PoolModelConfig[]): PresetModelDiff {
|
|
270
|
+
const poolById = new Map(poolModels.map((m) => [m.id, m]));
|
|
271
|
+
const presetById = new Map(presetModels.map((m) => [m.id, m]));
|
|
272
|
+
const added = presetModels.filter((m) => !poolById.has(m.id));
|
|
273
|
+
const removed = poolModels.filter((m) => !presetById.has(m.id));
|
|
274
|
+
const changed: PresetModelDiff["changed"] = [];
|
|
275
|
+
for (const presetModel of presetModels) {
|
|
276
|
+
const poolModel = poolById.get(presetModel.id);
|
|
277
|
+
if (!poolModel) continue;
|
|
278
|
+
const keys = new Set([...Object.keys(poolModel), ...Object.keys(presetModel)]);
|
|
279
|
+
const pool = poolModel as unknown as Record<string, unknown>;
|
|
280
|
+
const preset = presetModel as unknown as Record<string, unknown>;
|
|
281
|
+
const fields = [...keys].filter((key) => JSON.stringify(pool[key]) !== JSON.stringify(preset[key]));
|
|
282
|
+
if (fields.length > 0) changed.push({ id: presetModel.id, fields });
|
|
283
|
+
}
|
|
284
|
+
return { added, removed, changed };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Human-readable diff lines for prompts and menus (may be empty). */
|
|
288
|
+
export function describePresetDiff(diff: PresetModelDiff): string[] {
|
|
289
|
+
const lines: string[] = [];
|
|
290
|
+
if (diff.added.length > 0) lines.push(`+ added by preset: ${diff.added.map((m) => m.id).join(", ")}`);
|
|
291
|
+
if (diff.removed.length > 0) lines.push(`− removed from preset: ${diff.removed.map((m) => m.id).join(", ")}`);
|
|
292
|
+
for (const change of diff.changed) lines.push(`~ changed: ${change.id} (${change.fields.join(", ")})`);
|
|
293
|
+
return lines;
|
|
294
|
+
}
|
|
295
|
+
|
|
239
296
|
/** Materialize a preset into a pool config with the given keys. */
|
|
240
297
|
export function poolFromPreset(preset: Preset, poolId: string, keys: string[]): PoolConfig {
|
|
241
298
|
return {
|
|
@@ -249,5 +306,7 @@ export function poolFromPreset(preset: Preset, poolId: string, keys: string[]):
|
|
|
249
306
|
keys: keys.map((key, i) => ({ key, label: `key-${i + 1}`, enabled: true })),
|
|
250
307
|
// Deep copy so per-pool edits never mutate the shipped preset.
|
|
251
308
|
models: JSON.parse(JSON.stringify(preset.models)) as PoolModelConfig[],
|
|
309
|
+
// Track the preset version so future preset updates can offer a one-time align.
|
|
310
|
+
_preset: { id: preset.id, fingerprint: presetFingerprint(preset) },
|
|
252
311
|
};
|
|
253
312
|
}
|
package/probe.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { endpointHeaders } from "./config.ts";
|
|
1
|
+
import { endpointHeaders, endpointIdentityHeaders } from "./config.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Endpoint probing: auto-detect the auth header style and fetch the model list.
|
|
@@ -63,7 +63,7 @@ async function fetchJson(url: string, headers: Record<string, string>, timeoutMs
|
|
|
63
63
|
try {
|
|
64
64
|
const response = await fetch(url, {
|
|
65
65
|
method: "GET",
|
|
66
|
-
headers: { Accept: "application/json", ...endpointHeaders(baseUrl ?? url), ...headers },
|
|
66
|
+
headers: { Accept: "application/json", ...endpointHeaders(baseUrl ?? url), ...endpointIdentityHeaders(baseUrl ?? url), ...headers },
|
|
67
67
|
signal: AbortSignal.timeout(timeoutMs),
|
|
68
68
|
});
|
|
69
69
|
let body: unknown;
|
|
@@ -149,7 +149,12 @@ async function chatProbe(baseUrl: string, style: AuthStyle, key: string, modelId
|
|
|
149
149
|
try {
|
|
150
150
|
const response = await fetch(`${trimSlash(baseUrl)}/chat/completions`, {
|
|
151
151
|
method: "POST",
|
|
152
|
-
headers: {
|
|
152
|
+
headers: {
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
...endpointHeaders(baseUrl),
|
|
155
|
+
...endpointIdentityHeaders(baseUrl),
|
|
156
|
+
...authHeaders(style, key),
|
|
157
|
+
},
|
|
153
158
|
body: JSON.stringify({ model: modelId, max_tokens: 4, messages: [{ role: "user", content: "ping" }] }),
|
|
154
159
|
signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
|
|
155
160
|
});
|
package/stream.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rotating streamSimple: wraps the underlying pi-ai API implementation, picks a
|
|
3
3
|
* key from the pool for every request, and transparently retries on 429/401/403
|
|
4
|
-
* with the next key (marking a cooldown on the failed key).
|
|
4
|
+
* with the next key (marking a cooldown on the failed key). OAuth-backed keys
|
|
5
|
+
* (Cline accounts) refresh their access token pre-request and on 401, and a
|
|
6
|
+
* Cline daily-quota 429 cools the key down until the server-reported reset
|
|
7
|
+
* time instead of triggering normal rotation.
|
|
5
8
|
*
|
|
6
9
|
* Events are only relayed to the caller once the attempt is known to be healthy,
|
|
7
10
|
* so a rotated attempt never produces duplicate/partial output.
|
|
@@ -19,9 +22,29 @@ import {
|
|
|
19
22
|
type SimpleStreamOptions,
|
|
20
23
|
} from "@earendil-works/pi-ai";
|
|
21
24
|
import type { KeyOutcome, KeyPool, Lease } from "./pool.ts";
|
|
25
|
+
import { endpointIdentityHeaders } from "./config.ts";
|
|
26
|
+
import { ensureClineAccessToken } from "./cline-auth.ts";
|
|
22
27
|
|
|
23
28
|
const RATE_LIMIT_RE = /\b429\b|rate\s*limit|too many requests|quota\s*(exceed|limit)|requests per minute|requests per day/i;
|
|
24
29
|
const INVALID_KEY_RE = /\b40[13]\b|unauthorized|forbidden|invalid\s*(api\s*)?key|incorrect\s*(api\s*)?key|authentication/i;
|
|
30
|
+
/**
|
|
31
|
+
* Cline free models enforce a daily per-account quota and answer with
|
|
32
|
+
* `"Daily free limit reached on model X. Try again in 23h 59m"`. This is NOT
|
|
33
|
+
* a rotation-worthy 429 — the cooldown is the server-reported reset time.
|
|
34
|
+
*/
|
|
35
|
+
const CLINE_QUOTA_RE = /free limit reached on model/i;
|
|
36
|
+
const CLINE_RESET_RE = /try again in\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?/i;
|
|
37
|
+
|
|
38
|
+
/** Parse the "Try again in Xh Ym Zs" countdown from a Cline quota 429 body. */
|
|
39
|
+
export function parseClineQuotaResetMs(message: string): number | undefined {
|
|
40
|
+
const match = CLINE_RESET_RE.exec(message.toLowerCase());
|
|
41
|
+
if (!match) return undefined;
|
|
42
|
+
const hours = Number(match[1] ?? 0);
|
|
43
|
+
const minutes = Number(match[2] ?? 0);
|
|
44
|
+
const seconds = Number(match[3] ?? 0);
|
|
45
|
+
const ms = hours * 3_600_000 + minutes * 60_000 + seconds * 1000;
|
|
46
|
+
return ms > 0 ? ms : undefined;
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
interface CapturedResponse {
|
|
27
50
|
status: number;
|
|
@@ -30,7 +53,7 @@ interface CapturedResponse {
|
|
|
30
53
|
|
|
31
54
|
export type Notifier = (message: string) => void;
|
|
32
55
|
|
|
33
|
-
export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notify: Notifier) {
|
|
56
|
+
export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notify: Notifier, onConfigDirty?: () => void) {
|
|
34
57
|
const impl = getApiProvider(apiName as Api);
|
|
35
58
|
if (!impl) throw new Error(`multikey: no API provider registered for api: ${apiName}`);
|
|
36
59
|
// Auth style: "api-key" providers want the key in x-api-key (some reject
|
|
@@ -50,19 +73,48 @@ export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notif
|
|
|
50
73
|
|
|
51
74
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
52
75
|
let lease: Lease;
|
|
76
|
+
// One forced-token-refresh retry per acquired lease (401 → refresh → retry).
|
|
77
|
+
let refreshedForLease = false;
|
|
53
78
|
try {
|
|
54
79
|
lease = await pool.acquire(options?.signal);
|
|
55
80
|
} catch {
|
|
56
81
|
emitAborted(out, model);
|
|
57
82
|
return;
|
|
58
83
|
}
|
|
84
|
+
// OAuth-backed keys (Cline accounts) don't carry a static key: mint a
|
|
85
|
+
// fresh access token from the stored refresh token before every request.
|
|
86
|
+
let apiKey = lease.key;
|
|
87
|
+
if (lease.credential?.kind === "cline-oauth") {
|
|
88
|
+
try {
|
|
89
|
+
const fresh = await ensureClineAccessToken(lease.credential);
|
|
90
|
+
if (fresh.refreshed) {
|
|
91
|
+
pool.applyClineCredential(lease, fresh);
|
|
92
|
+
onConfigDirty?.();
|
|
93
|
+
}
|
|
94
|
+
apiKey = fresh.accessToken;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// The stale token may still work; if not, the 401 path below
|
|
97
|
+
// force-refreshes once before giving up on this key.
|
|
98
|
+
notify(
|
|
99
|
+
`multikey[${pool.config.id}]: token refresh failed on ${lease.label} (${error instanceof Error ? error.message : String(error)}) — trying stored token`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
59
103
|
|
|
60
104
|
try {
|
|
61
105
|
const captured: CapturedResponse = { status: 0 };
|
|
106
|
+
// Identity headers (session / device) are per-request, so they are merged
|
|
107
|
+
// here rather than baked into the provider registration. Providers apply
|
|
108
|
+
// options.headers last, so these win over the static pool headers.
|
|
109
|
+
const identityBaseUrl = model.baseUrl || pool.config.baseUrl;
|
|
62
110
|
const attemptOptions: SimpleStreamOptions = {
|
|
63
111
|
...options,
|
|
64
|
-
apiKey
|
|
65
|
-
headers:
|
|
112
|
+
apiKey,
|
|
113
|
+
headers: {
|
|
114
|
+
...options?.headers,
|
|
115
|
+
...endpointIdentityHeaders(identityBaseUrl),
|
|
116
|
+
...(authStyle === "api-key" ? { "x-api-key": apiKey } : {}),
|
|
117
|
+
},
|
|
66
118
|
onResponse: (response) => {
|
|
67
119
|
captured.status = response.status;
|
|
68
120
|
const ra = response.headers?.["retry-after"];
|
|
@@ -82,10 +134,31 @@ export function createRotatingStreamSimple(pool: KeyPool, apiName: string, notif
|
|
|
82
134
|
// Attempt failed. Only rotate when nothing was relayed yet; if content
|
|
83
135
|
// already streamed, the failure is surfaced as-is (mid-stream 429 is rare).
|
|
84
136
|
if (verdict.kind === "rotate" && !verdict.relayedAny) {
|
|
137
|
+
// 401 on an OAuth key usually means an expired access token: force a
|
|
138
|
+
// refresh and retry the same account once, without burning a rotation.
|
|
139
|
+
if (
|
|
140
|
+
verdict.outcome === "invalid" &&
|
|
141
|
+
lease.credential?.kind === "cline-oauth" &&
|
|
142
|
+
!refreshedForLease
|
|
143
|
+
) {
|
|
144
|
+
refreshedForLease = true;
|
|
145
|
+
try {
|
|
146
|
+
const fresh = await ensureClineAccessToken(lease.credential, { force: true });
|
|
147
|
+
pool.applyClineCredential(lease, fresh);
|
|
148
|
+
onConfigDirty?.();
|
|
149
|
+
notify(`multikey[${pool.config.id}]: 401 on ${lease.label} — Cline token refreshed, retrying`);
|
|
150
|
+
attempt--; // retry the same key; the request never got going
|
|
151
|
+
continue;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
notify(
|
|
154
|
+
`multikey[${pool.config.id}]: Cline token refresh failed on ${lease.label} (${error instanceof Error ? error.message : String(error)})`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
85
158
|
lastProblem = verdict.problem;
|
|
86
|
-
pool.report(lease, verdict.outcome, verdict.
|
|
159
|
+
pool.report(lease, verdict.outcome, verdict.cooldownMs);
|
|
87
160
|
notify(
|
|
88
|
-
`multikey[${pool.config.id}]: ${verdict.outcome
|
|
161
|
+
`multikey[${pool.config.id}]: ${describeOutcome(verdict.outcome)} on ${lease.label} (${pool.mask(lease.key)}), rotating to another key`,
|
|
89
162
|
);
|
|
90
163
|
continue;
|
|
91
164
|
}
|
|
@@ -198,6 +271,11 @@ function classify(
|
|
|
198
271
|
message: string,
|
|
199
272
|
captured: CapturedResponse,
|
|
200
273
|
): { outcome: KeyOutcome; cooldownMs?: number } | undefined {
|
|
274
|
+
// Quota marker wins over the generic 429/status rules: the reset countdown
|
|
275
|
+
// in the body is the real cooldown, not the retry-after header.
|
|
276
|
+
if (CLINE_QUOTA_RE.test(message)) {
|
|
277
|
+
return { outcome: "quota_exhausted", cooldownMs: parseClineQuotaResetMs(message) };
|
|
278
|
+
}
|
|
201
279
|
const fromStatus = classifyStatus(captured);
|
|
202
280
|
if (fromStatus) return fromStatus;
|
|
203
281
|
if (RATE_LIMIT_RE.test(message)) return { outcome: "rate_limited" };
|
|
@@ -205,6 +283,20 @@ function classify(
|
|
|
205
283
|
return undefined;
|
|
206
284
|
}
|
|
207
285
|
|
|
286
|
+
/** Short human label for a rotation notify line. */
|
|
287
|
+
function describeOutcome(outcome: KeyOutcome): string {
|
|
288
|
+
switch (outcome) {
|
|
289
|
+
case "rate_limited":
|
|
290
|
+
return "429";
|
|
291
|
+
case "quota_exhausted":
|
|
292
|
+
return "daily free limit reached";
|
|
293
|
+
case "invalid":
|
|
294
|
+
return "auth error";
|
|
295
|
+
default:
|
|
296
|
+
return "error";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
208
300
|
function baseMessage(model: Model<Api>, stopReason: "error" | "aborted", errorMessage?: string): AssistantMessage {
|
|
209
301
|
return {
|
|
210
302
|
role: "assistant",
|