@maxiaochao/pi-toolkit 0.11.7 → 0.11.8
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/CHANGELOG.md +7 -0
- package/README.md +1 -0
- package/package.json +5 -3
- package/packages/openai-account-pool/CHANGELOG.md +11 -0
- package/packages/openai-account-pool/README.md +67 -0
- package/packages/openai-account-pool/TESTING.md +28 -0
- package/packages/openai-account-pool/docs/demo.gif +0 -0
- package/packages/openai-account-pool/docs/screenshot.png +0 -0
- package/packages/openai-account-pool/extensions/openai-account-pool.ts +368 -0
- package/packages/openai-account-pool/package.json +45 -0
- package/packages/openai-account-pool/src/auth.ts +418 -0
- package/packages/openai-account-pool/src/availability.ts +230 -0
- package/packages/openai-account-pool/src/command-completions.ts +18 -0
- package/packages/openai-account-pool/src/enrollment.ts +339 -0
- package/packages/openai-account-pool/src/lease.ts +128 -0
- package/packages/openai-account-pool/src/lock.ts +171 -0
- package/packages/openai-account-pool/src/login-slot.ts +158 -0
- package/packages/openai-account-pool/src/policy.ts +176 -0
- package/packages/openai-account-pool/src/provider.ts +511 -0
- package/packages/openai-account-pool/src/status-widget.ts +62 -0
- package/packages/openai-account-pool/src/store.ts +259 -0
- package/packages/openai-account-pool/src/usage.ts +291 -0
- package/packages/openai-account-pool/tests/command-completions.mjs +10 -0
- package/packages/openai-account-pool/tests/enrollment.mjs +322 -0
- package/packages/openai-account-pool/tests/failover-stream.mjs +554 -0
- package/packages/openai-account-pool/tests/helpers/fixtures.mjs +40 -0
- package/packages/openai-account-pool/tests/helpers/native-credential-store.mjs +58 -0
- package/packages/openai-account-pool/tests/helpers/pi-runtime.mjs +24 -0
- package/packages/openai-account-pool/tests/helpers/refresh-child.mjs +29 -0
- package/packages/openai-account-pool/tests/integration-login-ownership.mjs +299 -0
- package/packages/openai-account-pool/tests/login-slot.mjs +131 -0
- package/packages/openai-account-pool/tests/multiprocess.mjs +73 -0
- package/packages/openai-account-pool/tests/selection.mjs +286 -0
- package/packages/openai-account-pool/tests/store-ownership.mjs +369 -0
- package/packages/openai-account-pool/tests/usage.mjs +428 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Api,
|
|
3
|
+
AssistantMessageEvent,
|
|
4
|
+
AssistantMessageEventStream,
|
|
5
|
+
Model,
|
|
6
|
+
ModelAuth,
|
|
7
|
+
OAuthCredential,
|
|
8
|
+
Provider,
|
|
9
|
+
SimpleStreamOptions,
|
|
10
|
+
StreamOptions,
|
|
11
|
+
TranscriptContext,
|
|
12
|
+
} from "@earendil-works/pi-ai";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ensureFreshAccountCredential,
|
|
16
|
+
isPoolReferenceEntry,
|
|
17
|
+
loadPoolSnapshot,
|
|
18
|
+
maskAccountId,
|
|
19
|
+
POOL_UNRESOLVED_API_KEY,
|
|
20
|
+
type PoolAccount,
|
|
21
|
+
type PoolSnapshot,
|
|
22
|
+
} from "./auth.ts";
|
|
23
|
+
import { claimProbeLease, readAvailability, readPreferredAccountId, readSelectionState, recordFailure, recordSuccess, releaseProbeLease } from "./availability.ts";
|
|
24
|
+
import {
|
|
25
|
+
classifyFailure,
|
|
26
|
+
decideFailover,
|
|
27
|
+
selectAccount,
|
|
28
|
+
type AccountAvailability,
|
|
29
|
+
type Classification,
|
|
30
|
+
type FailureEvidence,
|
|
31
|
+
type ProviderErrorBody,
|
|
32
|
+
} from "./policy.ts";
|
|
33
|
+
import { readPoolState, type AuthEntry } from "./store.ts";
|
|
34
|
+
import { describeSoonestReady, refreshPoolUsage } from "./usage.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wraps the built-in `openai-codex` provider so one model request can be retried on another
|
|
38
|
+
* pooled account. The wrapper only ever adds account selection: the request itself, including
|
|
39
|
+
* every caller callback, still runs through the built-in adapter.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Error bodies are small; read enough to classify and no more. */
|
|
43
|
+
const MAX_ERROR_BODY_BYTES = 64 * 1024;
|
|
44
|
+
/** Events held back before the first real output, so a dead attempt can still be discarded. */
|
|
45
|
+
const MAX_BUFFERED_EVENTS = 64;
|
|
46
|
+
|
|
47
|
+
export interface PoolRuntime {
|
|
48
|
+
/** pi-ai's `lazyStream`, injected so this module needs no runtime import of pi-ai. */
|
|
49
|
+
lazyStream: (model: Model<Api>, setup: () => Promise<AsyncIterable<AssistantMessageEvent>>) => AssistantMessageEventStream;
|
|
50
|
+
authPath: string;
|
|
51
|
+
statePath: string;
|
|
52
|
+
now?: () => number;
|
|
53
|
+
/** Identifies this process while probing a recovered account. */
|
|
54
|
+
probeOwner?: string;
|
|
55
|
+
/** Shows a short, non-secret note when the pool switches or gives up. */
|
|
56
|
+
notify?: (message: string) => void;
|
|
57
|
+
/** Test seam for the private usage endpoint; ordinary model requests do not use it. */
|
|
58
|
+
usageFetch?: typeof globalThis.fetch;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type StreamKind = "stream" | "streamSimple";
|
|
62
|
+
|
|
63
|
+
function isMeaningfulOutput(event: AssistantMessageEvent): boolean {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case "text_delta":
|
|
66
|
+
case "text_end":
|
|
67
|
+
case "thinking_delta":
|
|
68
|
+
case "thinking_end":
|
|
69
|
+
case "toolcall_delta":
|
|
70
|
+
case "toolcall_end":
|
|
71
|
+
case "done":
|
|
72
|
+
return true;
|
|
73
|
+
default:
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readBoundedText(response: Response): Promise<string> {
|
|
79
|
+
const body = response.body;
|
|
80
|
+
if (!body) return "";
|
|
81
|
+
const reader = body.getReader();
|
|
82
|
+
const chunks: Uint8Array[] = [];
|
|
83
|
+
let size = 0;
|
|
84
|
+
try {
|
|
85
|
+
while (size < MAX_ERROR_BODY_BYTES) {
|
|
86
|
+
const { done, value } = await reader.read();
|
|
87
|
+
if (done) break;
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
size += value.byteLength;
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
await reader.cancel().catch(() => {});
|
|
93
|
+
}
|
|
94
|
+
return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, MAX_ERROR_BODY_BYTES));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function errorBodyOf(payload: unknown): ProviderErrorBody | undefined {
|
|
98
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
99
|
+
const record = payload as Record<string, unknown>;
|
|
100
|
+
const nested = record.error;
|
|
101
|
+
if (typeof nested === "object" && nested !== null) return nested as ProviderErrorBody;
|
|
102
|
+
if (typeof record.code === "string" || typeof record.type === "string") return record as ProviderErrorBody;
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isAbortError(error: unknown): boolean {
|
|
107
|
+
return error instanceof Error && (error.name === "AbortError" || error.message === "Request was aborted");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Observes the provider's own HTTP responses and stream events to learn what actually failed.
|
|
112
|
+
* The adapter's user-facing message cannot be used: it formats a plain 429 with the same
|
|
113
|
+
* "ChatGPT usage limit" wording as a real subscription limit.
|
|
114
|
+
*/
|
|
115
|
+
function instrument<TOptions extends StreamOptions>(options: TOptions | undefined, apiKey: string, modelId: string): { options: TOptions; evidence: () => FailureEvidence } {
|
|
116
|
+
let status: number | undefined;
|
|
117
|
+
let body: ProviderErrorBody | undefined;
|
|
118
|
+
let networkFailure = false;
|
|
119
|
+
|
|
120
|
+
const innerFetch = options?.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init));
|
|
121
|
+
const wrappedFetch: typeof globalThis.fetch = async (input, init) => {
|
|
122
|
+
let response: Response;
|
|
123
|
+
try {
|
|
124
|
+
response = await innerFetch(input, init);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!isAbortError(error)) networkFailure = true;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
status = response.status;
|
|
131
|
+
try {
|
|
132
|
+
body = errorBodyOf(JSON.parse(await readBoundedText(response.clone()))) ?? body;
|
|
133
|
+
} catch {
|
|
134
|
+
// An unparsable error body still leaves the status as evidence.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return response;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const innerStreamEvent = options?.onProviderStreamEvent;
|
|
141
|
+
const wrappedStreamEvent = async (data: unknown, model: Model<Api>) => {
|
|
142
|
+
try {
|
|
143
|
+
const event = data as { type?: unknown; response?: unknown } | null;
|
|
144
|
+
if (event && typeof event === "object") {
|
|
145
|
+
const failed = event.type === "response.failed" ? errorBodyOf(event.response) : undefined;
|
|
146
|
+
body = failed ?? (event.type === "error" ? errorBodyOf(event) : undefined) ?? body;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Observation must never break the request.
|
|
150
|
+
}
|
|
151
|
+
await innerStreamEvent?.(data, model);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
options: { ...(options ?? ({} as TOptions)), apiKey, fetch: wrappedFetch, onProviderStreamEvent: wrappedStreamEvent },
|
|
156
|
+
evidence: () => ({ status, body, networkFailure, modelId }),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Classifies a failed token exchange without putting the response text anywhere. */
|
|
161
|
+
function classifyRefreshError(error: unknown, modelId: string, now: number): Classification {
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
163
|
+
if (isAbortError(error) || message.includes("Login cancelled")) return classifyFailure({ cancelled: true, modelId }, now);
|
|
164
|
+
// OAuth reports a proven-dead grant with this exact protocol code.
|
|
165
|
+
const invalidGrant = /"error"\s*:\s*"invalid_grant"|\binvalid_grant\b/.test(message);
|
|
166
|
+
return classifyFailure({ oauthRefresh: { invalidGrant }, modelId }, now);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type AttemptOutcome =
|
|
170
|
+
| { kind: "success" }
|
|
171
|
+
| { kind: "stop" }
|
|
172
|
+
| { kind: "failover"; classification: Classification }
|
|
173
|
+
| { kind: "repair"; classification: Classification };
|
|
174
|
+
|
|
175
|
+
export function createPoolProvider(base: Provider, runtime: PoolRuntime): Provider {
|
|
176
|
+
const now = runtime.now ?? (() => Date.now());
|
|
177
|
+
const probeOwner = runtime.probeOwner ?? `pid-${process.pid}`;
|
|
178
|
+
const oauth = base.auth.oauth;
|
|
179
|
+
/** Sticky account of this process: keep using what works until it stops working. */
|
|
180
|
+
let sticky: string | undefined;
|
|
181
|
+
|
|
182
|
+
const refreshWith = async (credential: AuthEntry, signal: AbortSignal | undefined): Promise<AuthEntry> => {
|
|
183
|
+
if (!oauth) throw new Error("openai-codex provider has no OAuth support.");
|
|
184
|
+
const refreshed = await oauth.refresh(credential as unknown as OAuthCredential, signal ?? new AbortController().signal);
|
|
185
|
+
return refreshed as unknown as AuthEntry;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** The pool only applies to the provider's own Codex endpoint with pool-resolved auth. */
|
|
189
|
+
const usesPool = (model: Model<Api>, options: StreamOptions | undefined): boolean => {
|
|
190
|
+
if (options?.apiKey !== POOL_UNRESOLVED_API_KEY) return false;
|
|
191
|
+
if (!base.baseUrl) return true;
|
|
192
|
+
return model.baseUrl.startsWith(base.baseUrl);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function poolExhaustedText(availability: readonly AccountAvailability[]): string {
|
|
196
|
+
return `ChatGPT 账号池暂时都不可用(${describeUnavailable(availability)})。可用 /codex_pool status 查看,或 /codex_pool 添加账号。`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function poolExhaustedMessage(model: Model<Api>, accounts: readonly PoolAccount[]): Promise<string> {
|
|
200
|
+
const availability = await readAvailability({ statePath: runtime.statePath, accounts, now: now(), modelId: model.id, probeOwner });
|
|
201
|
+
return poolExhaustedTextWithRecovery(availability, accounts, model.id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function poolExhaustedTextWithRecovery(availability: readonly AccountAvailability[], accounts: readonly PoolAccount[], modelId: string): Promise<string> {
|
|
205
|
+
const basic = poolExhaustedText(availability);
|
|
206
|
+
const at = now();
|
|
207
|
+
const recovery = describeSoonestReady({ accounts, state: await readPoolState(runtime.statePath), now: at, modelId });
|
|
208
|
+
return recovery ? `${basic} ${recovery}。` : basic;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function describeUnavailable(availability: readonly AccountAvailability[]): string {
|
|
212
|
+
const parts = availability.map((entry) => {
|
|
213
|
+
const label = maskAccountId(entry.id);
|
|
214
|
+
if (entry.authInvalid) return `${label}:需要重新授权`;
|
|
215
|
+
const until = entry.unavailable?.serverResetAtMs;
|
|
216
|
+
if (until) return `${label}:约 ${Math.max(1, Math.round((until - now()) / 60_000))} 分钟后恢复`;
|
|
217
|
+
if (entry.unavailable?.reason === "probe_in_progress") return `${label}:另一个进程正在重试`;
|
|
218
|
+
return `${label}:恢复时间未知`;
|
|
219
|
+
});
|
|
220
|
+
return parts.join(";");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Runs one attempt on one account. Events are held back until real output appears, so an
|
|
225
|
+
* attempt that fails with nothing to show can be dropped and retried on another account.
|
|
226
|
+
*/
|
|
227
|
+
async function* runAttempt(
|
|
228
|
+
inner: AsyncIterable<AssistantMessageEvent>,
|
|
229
|
+
evidence: () => FailureEvidence,
|
|
230
|
+
contextOf: {
|
|
231
|
+
signal?: AbortSignal;
|
|
232
|
+
repairUsed: boolean;
|
|
233
|
+
hasRemainingCandidate: boolean;
|
|
234
|
+
// A terminal event ends the caller's iteration, so shared state is written before it.
|
|
235
|
+
beforeStop: (classification: Classification) => Promise<void>;
|
|
236
|
+
beforeSuccess: () => Promise<void>;
|
|
237
|
+
},
|
|
238
|
+
): AsyncGenerator<AssistantMessageEvent, AttemptOutcome> {
|
|
239
|
+
const buffered: AssistantMessageEvent[] = [];
|
|
240
|
+
let flushed = false;
|
|
241
|
+
|
|
242
|
+
for await (const event of inner) {
|
|
243
|
+
if (event.type === "error") {
|
|
244
|
+
const cancelled = contextOf.signal?.aborted || event.reason === "aborted";
|
|
245
|
+
const classification = classifyFailure({ ...evidence(), cancelled }, now());
|
|
246
|
+
const decision = decideFailover({
|
|
247
|
+
classification,
|
|
248
|
+
emittedOutput: flushed,
|
|
249
|
+
hasRemainingCandidate: contextOf.hasRemainingCandidate,
|
|
250
|
+
repairUsed: contextOf.repairUsed,
|
|
251
|
+
});
|
|
252
|
+
if (decision.action === "stop") {
|
|
253
|
+
await contextOf.beforeStop(classification);
|
|
254
|
+
yield* buffered;
|
|
255
|
+
yield event;
|
|
256
|
+
return { kind: "stop" };
|
|
257
|
+
}
|
|
258
|
+
return { kind: decision.action === "repair" ? "repair" : "failover", classification };
|
|
259
|
+
}
|
|
260
|
+
if (event.type === "done") {
|
|
261
|
+
await contextOf.beforeSuccess();
|
|
262
|
+
yield* buffered;
|
|
263
|
+
yield event;
|
|
264
|
+
return { kind: "success" };
|
|
265
|
+
}
|
|
266
|
+
if (flushed) {
|
|
267
|
+
yield event;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
buffered.push(event);
|
|
271
|
+
if (isMeaningfulOutput(event) || buffered.length >= MAX_BUFFERED_EVENTS) {
|
|
272
|
+
flushed = true;
|
|
273
|
+
yield* buffered;
|
|
274
|
+
buffered.length = 0;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
yield* buffered;
|
|
278
|
+
throw new Error("Codex 响应在结束前中断。");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function* runWithPool(
|
|
282
|
+
kind: StreamKind,
|
|
283
|
+
model: Model<Api>,
|
|
284
|
+
context: TranscriptContext,
|
|
285
|
+
options: StreamOptions | SimpleStreamOptions | undefined,
|
|
286
|
+
snapshot: PoolSnapshot,
|
|
287
|
+
): AsyncGenerator<AssistantMessageEvent> {
|
|
288
|
+
const attempted: string[] = [];
|
|
289
|
+
const repaired = new Set<string>();
|
|
290
|
+
let lastAvailability: readonly AccountAvailability[] = [];
|
|
291
|
+
let usageRefreshAttempted = false;
|
|
292
|
+
|
|
293
|
+
while (true) {
|
|
294
|
+
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
295
|
+
const { availability, preferredAccountId } = await readSelectionState({
|
|
296
|
+
statePath: runtime.statePath,
|
|
297
|
+
accounts: snapshot.accounts,
|
|
298
|
+
now: now(),
|
|
299
|
+
modelId: model.id,
|
|
300
|
+
probeOwner,
|
|
301
|
+
});
|
|
302
|
+
lastAvailability = availability;
|
|
303
|
+
// A hand-picked account outranks this process's sticky one for as long as it is usable.
|
|
304
|
+
const chosen = selectAccount({ accounts: availability, preferredId: preferredAccountId ?? sticky, attemptedIds: attempted });
|
|
305
|
+
if (!chosen) {
|
|
306
|
+
// Query live usage only when the request began with no local candidate. Once a
|
|
307
|
+
// model attempt has failed, a sweep must not erase `attempted` and replay it.
|
|
308
|
+
if (!usageRefreshAttempted && attempted.length === 0) {
|
|
309
|
+
usageRefreshAttempted = true;
|
|
310
|
+
await refreshPoolUsage({
|
|
311
|
+
authPath: runtime.authPath,
|
|
312
|
+
statePath: runtime.statePath,
|
|
313
|
+
accounts: snapshot.accounts,
|
|
314
|
+
refresh: refreshWith,
|
|
315
|
+
now,
|
|
316
|
+
fetch: runtime.usageFetch,
|
|
317
|
+
signal: options?.signal,
|
|
318
|
+
});
|
|
319
|
+
attempted.length = 0;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const account = snapshot.accounts.find((candidate) => candidate.id === chosen.id);
|
|
326
|
+
if (!account) break;
|
|
327
|
+
attempted.push(account.id);
|
|
328
|
+
|
|
329
|
+
// A recovered account is retried by one process at a time, not by all of them at once.
|
|
330
|
+
let holdsProbe = false;
|
|
331
|
+
if (chosen.needsProbe) {
|
|
332
|
+
holdsProbe = await claimProbeLease({ statePath: runtime.statePath, accountId: account.id, owner: probeOwner, now: now() });
|
|
333
|
+
if (!holdsProbe) continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const outcome = yield* attemptAccount(kind, model, context, options, account, {
|
|
338
|
+
repairUsed: repaired.has(account.id),
|
|
339
|
+
clearsState: chosen.needsProbe || chosen.authInvalid,
|
|
340
|
+
hasRemainingCandidate: availability.some(
|
|
341
|
+
(entry) => !attempted.includes(entry.id) && !entry.unavailable && !entry.authInvalid,
|
|
342
|
+
),
|
|
343
|
+
markRepaired: () => repaired.add(account.id),
|
|
344
|
+
}, snapshot.accounts);
|
|
345
|
+
if (outcome.kind === "success" || outcome.kind === "stop") return;
|
|
346
|
+
if (outcome.kind === "repair") {
|
|
347
|
+
// The repair retries the same account and does not consume a failover candidate.
|
|
348
|
+
attempted.pop();
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const next = selectAccount({
|
|
352
|
+
accounts: await readAvailability({ statePath: runtime.statePath, accounts: snapshot.accounts, now: now(), modelId: model.id, probeOwner }),
|
|
353
|
+
preferredId: undefined,
|
|
354
|
+
attemptedIds: attempted,
|
|
355
|
+
});
|
|
356
|
+
if (next) {
|
|
357
|
+
runtime.notify?.(
|
|
358
|
+
outcome.classification.kind === "quota"
|
|
359
|
+
? `Codex 账号 ${maskAccountId(account.id)} 额度已用尽,切换到 ${maskAccountId(next.id)}。`
|
|
360
|
+
: `Codex 账号 ${maskAccountId(account.id)} 需要重新授权,切换到 ${maskAccountId(next.id)}。`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
} finally {
|
|
364
|
+
if (holdsProbe) await releaseProbeLease({ statePath: runtime.statePath, accountId: account.id, owner: probeOwner }).catch(() => {});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
throw new Error(await poolExhaustedTextWithRecovery(lastAvailability, snapshot.accounts, model.id));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function* attemptAccount(
|
|
372
|
+
kind: StreamKind,
|
|
373
|
+
model: Model<Api>,
|
|
374
|
+
context: TranscriptContext,
|
|
375
|
+
options: StreamOptions | SimpleStreamOptions | undefined,
|
|
376
|
+
account: PoolAccount,
|
|
377
|
+
gate: { repairUsed: boolean; hasRemainingCandidate: boolean; clearsState: boolean; markRepaired: () => void },
|
|
378
|
+
snapshotAccounts: readonly PoolAccount[],
|
|
379
|
+
): AsyncGenerator<AssistantMessageEvent, AttemptOutcome> {
|
|
380
|
+
const requestStartedAtMs = now();
|
|
381
|
+
let credential: AuthEntry;
|
|
382
|
+
try {
|
|
383
|
+
const fresh = await ensureFreshAccountCredential({
|
|
384
|
+
authPath: runtime.authPath,
|
|
385
|
+
account,
|
|
386
|
+
refresh: (current) => refreshWith(current, options?.signal),
|
|
387
|
+
signal: options?.signal,
|
|
388
|
+
});
|
|
389
|
+
credential = fresh.credential;
|
|
390
|
+
} catch (error) {
|
|
391
|
+
const classification = classifyRefreshError(error, model.id, now());
|
|
392
|
+
await recordFailure({ statePath: runtime.statePath, account, classification, now: now() });
|
|
393
|
+
const decision = decideFailover({ classification, emittedOutput: false, hasRemainingCandidate: gate.hasRemainingCandidate, repairUsed: true });
|
|
394
|
+
if (decision.action !== "failover") {
|
|
395
|
+
// The raw token response may echo request details, so only the reason code is surfaced.
|
|
396
|
+
if (classification.kind === "cancelled") throw new Error("Request was aborted");
|
|
397
|
+
throw new Error(`Codex 账号 ${maskAccountId(account.id)} 凭据刷新失败(${classification.reason})。`);
|
|
398
|
+
}
|
|
399
|
+
return { kind: "failover", classification };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const instrumented = instrument(options, String(credential.access), model.id);
|
|
403
|
+
const inner =
|
|
404
|
+
kind === "stream"
|
|
405
|
+
? base.stream(model, context, instrumented.options)
|
|
406
|
+
: base.streamSimple(model, context, instrumented.options as SimpleStreamOptions);
|
|
407
|
+
|
|
408
|
+
const outcome = yield* runAttempt(inner, instrumented.evidence, {
|
|
409
|
+
signal: options?.signal,
|
|
410
|
+
repairUsed: gate.repairUsed,
|
|
411
|
+
hasRemainingCandidate: gate.hasRemainingCandidate,
|
|
412
|
+
beforeStop: async (classification) => {
|
|
413
|
+
await recordFailure({ statePath: runtime.statePath, account, classification, now: now() });
|
|
414
|
+
const exhausting = classification.kind === "quota" || classification.kind === "auth_invalid";
|
|
415
|
+
// The provider's own error is forwarded untouched, so the pool status is a separate note.
|
|
416
|
+
if (exhausting && !gate.hasRemainingCandidate) runtime.notify?.(await poolExhaustedMessage(model, snapshotAccounts));
|
|
417
|
+
},
|
|
418
|
+
beforeSuccess: async () => {
|
|
419
|
+
sticky = account.id;
|
|
420
|
+
await recordSuccess({ statePath: runtime.statePath, account, requestStartedAtMs, clearEvidence: gate.clearsState });
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (outcome.kind === "success") return outcome;
|
|
425
|
+
if (outcome.kind === "repair") {
|
|
426
|
+
gate.markRepaired();
|
|
427
|
+
try {
|
|
428
|
+
// Re-read and rotate the exact token that was rejected, once.
|
|
429
|
+
await ensureFreshAccountCredential({
|
|
430
|
+
authPath: runtime.authPath,
|
|
431
|
+
account,
|
|
432
|
+
refresh: (current) => refreshWith(current, options?.signal),
|
|
433
|
+
force: true,
|
|
434
|
+
staleAccess: String(credential.access),
|
|
435
|
+
signal: options?.signal,
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const classification = classifyRefreshError(error, model.id, now());
|
|
439
|
+
await recordFailure({ statePath: runtime.statePath, account, classification, now: now() });
|
|
440
|
+
return { kind: "failover", classification };
|
|
441
|
+
}
|
|
442
|
+
return outcome;
|
|
443
|
+
}
|
|
444
|
+
if (outcome.kind === "failover") {
|
|
445
|
+
await recordFailure({ statePath: runtime.statePath, account, classification: outcome.classification, now: now() });
|
|
446
|
+
}
|
|
447
|
+
return outcome;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const streamThroughPool = (kind: StreamKind) => (model: Model<Api>, context: TranscriptContext, options?: StreamOptions) =>
|
|
451
|
+
runtime.lazyStream(model, async () => {
|
|
452
|
+
if (!usesPool(model, options)) {
|
|
453
|
+
return kind === "stream" ? base.stream(model, context, options) : base.streamSimple(model, context, options as SimpleStreamOptions);
|
|
454
|
+
}
|
|
455
|
+
const snapshot = await loadPoolSnapshot(runtime.authPath);
|
|
456
|
+
if (!snapshot.enabled || snapshot.accounts.length === 0) {
|
|
457
|
+
throw new Error("ChatGPT 账号池已启用但没有可用账号,请运行 /codex_pool 添加账号。");
|
|
458
|
+
}
|
|
459
|
+
return runWithPool(kind, model, context, options, snapshot);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const wrapped: Provider = {
|
|
463
|
+
...base,
|
|
464
|
+
auth: oauth
|
|
465
|
+
? {
|
|
466
|
+
...base.auth,
|
|
467
|
+
oauth: {
|
|
468
|
+
...oauth,
|
|
469
|
+
// The stored pool reference is not a token: requests resolve a real account later.
|
|
470
|
+
toAuth: async (credential): Promise<ModelAuth> =>
|
|
471
|
+
isPoolReferenceEntry(credential as unknown as AuthEntry) ? { apiKey: POOL_UNRESOLVED_API_KEY } : oauth.toAuth(credential),
|
|
472
|
+
refresh: async (credential, signal): Promise<OAuthCredential> =>
|
|
473
|
+
isPoolReferenceEntry(credential as unknown as AuthEntry) ? credential : oauth.refresh(credential, signal),
|
|
474
|
+
},
|
|
475
|
+
}
|
|
476
|
+
: base.auth,
|
|
477
|
+
stream: streamThroughPool("stream") as Provider["stream"],
|
|
478
|
+
streamSimple: streamThroughPool("streamSimple") as Provider["streamSimple"],
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Deferred responses belong to the account that started them, so they resolve auth without
|
|
482
|
+
// failover rather than inheriting the unresolved placeholder.
|
|
483
|
+
if (base.fetchDeferred) {
|
|
484
|
+
wrapped.fetchDeferred = (model, handle, options) =>
|
|
485
|
+
runtime.lazyStream(model, async () => base.fetchDeferred!(model, handle, await resolveDeferredOptions(model, options)));
|
|
486
|
+
}
|
|
487
|
+
if (base.cancelDeferred) {
|
|
488
|
+
wrapped.cancelDeferred = async (model, handle, options) => base.cancelDeferred!(model, handle, await resolveDeferredOptions(model, options));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function resolveDeferredOptions<TOptions extends { apiKey?: string; signal?: AbortSignal }>(
|
|
492
|
+
model: Model<Api>,
|
|
493
|
+
options: TOptions | undefined,
|
|
494
|
+
): Promise<TOptions | undefined> {
|
|
495
|
+
if (!usesPool(model, options as StreamOptions | undefined)) return options;
|
|
496
|
+
const snapshot = await loadPoolSnapshot(runtime.authPath);
|
|
497
|
+
const preferredAccountId = await readPreferredAccountId(runtime.statePath);
|
|
498
|
+
const byId = (wanted: string | undefined) => (wanted ? snapshot.accounts.find((candidate) => candidate.id === wanted) : undefined);
|
|
499
|
+
const account = byId(preferredAccountId) ?? byId(sticky) ?? snapshot.accounts[0];
|
|
500
|
+
if (!account) throw new Error("ChatGPT 账号池没有可用账号,请运行 /codex_pool 添加账号。");
|
|
501
|
+
const fresh = await ensureFreshAccountCredential({
|
|
502
|
+
authPath: runtime.authPath,
|
|
503
|
+
account,
|
|
504
|
+
refresh: (current) => refreshWith(current, options?.signal),
|
|
505
|
+
signal: options?.signal,
|
|
506
|
+
});
|
|
507
|
+
return { ...(options as TOptions), apiKey: String(fresh.credential.access) };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return wrapped;
|
|
511
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import type { AccountState, PoolStatus } from "./enrollment.ts";
|
|
5
|
+
|
|
6
|
+
function marker(state: AccountState): string {
|
|
7
|
+
if (state === "ready") return "●";
|
|
8
|
+
if (state === "cooling") return "◒";
|
|
9
|
+
if (state === "probing") return "◌";
|
|
10
|
+
return "!";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stateColor(state: AccountState): "success" | "warning" | "error" {
|
|
14
|
+
if (state === "ready") return "success";
|
|
15
|
+
if (state === "reauth") return "error";
|
|
16
|
+
return "warning";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatAccountStatusLine(account: PoolStatus["accounts"][number]): string {
|
|
20
|
+
return `${account.label} ${account.detail}${account.isCurrent ? " ●当前" : ""}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CodexPoolStatusWidget {
|
|
24
|
+
private readonly theme: Theme;
|
|
25
|
+
private status?: PoolStatus;
|
|
26
|
+
private loading = true;
|
|
27
|
+
private updateFailed = false;
|
|
28
|
+
|
|
29
|
+
constructor(theme: Theme) {
|
|
30
|
+
this.theme = theme;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
update(status: PoolStatus | undefined, options: { loading: boolean; updateFailed?: boolean }): void {
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.loading = options.loading;
|
|
36
|
+
this.updateFailed = options.updateFailed ?? false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
render(width: number): string[] {
|
|
40
|
+
if (width < 1) return [];
|
|
41
|
+
const ready = this.status?.accounts.filter((account) => account.state === "ready").length ?? 0;
|
|
42
|
+
const total = this.status?.accounts.length ?? 0;
|
|
43
|
+
const lines = [truncateToWidth(`${this.theme.fg("accent", "◆ Codex 账号池")} ${total > 0 ? this.theme.fg("muted", `· ${ready}/${total} 可用`) : ""}`, width)];
|
|
44
|
+
if (!this.status) {
|
|
45
|
+
lines.push(truncateToWidth(` ${this.theme.fg("warning", "◌")} ${this.theme.fg("muted", "正在读取账号池状态…")}`, width));
|
|
46
|
+
} else if (!this.status.enabled) {
|
|
47
|
+
lines.push(truncateToWidth(` ${this.theme.fg("warning", this.status.summary)}`, width));
|
|
48
|
+
}
|
|
49
|
+
if (this.loading) lines.push(truncateToWidth(` ${this.theme.fg("warning", "◌ 正在更新官网额度…")}`, width));
|
|
50
|
+
else if (this.updateFailed) lines.push(truncateToWidth(` ${this.theme.fg("warning", "官网额度更新失败,显示上次缓存")}`, width));
|
|
51
|
+
for (const account of this.status?.accounts ?? []) {
|
|
52
|
+
const color = stateColor(account.state);
|
|
53
|
+
const base = ` ${this.theme.fg(color, marker(account.state))} ${this.theme.fg("accent", account.label)} ${this.theme.fg("text", account.detail)}`;
|
|
54
|
+
const current = account.isCurrent ? ` ${this.theme.fg("accent", "●当前")}` : "";
|
|
55
|
+
const baseWidth = Math.max(0, width - visibleWidth(current));
|
|
56
|
+
lines.push(`${truncateToWidth(base, baseWidth)}${current}`);
|
|
57
|
+
}
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
invalidate(): void {}
|
|
62
|
+
}
|