@bitkyc08/opencodex 2.6.12 → 2.6.14-preview.20260701
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/gui/dist/assets/{index-CTjsL04v.js → index-Cy2tzg-5.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +8 -3
- package/src/adapters/client-fingerprint.ts +4 -0
- package/src/adapters/google.ts +0 -3
- package/src/adapters/kiro-wire.ts +2 -2
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +104 -69
- package/src/adapters/openai-responses.ts +36 -1
- package/src/cli-help.ts +2 -0
- package/src/cli.ts +5 -0
- package/src/codex-auth-api.ts +51 -0
- package/src/codex-auth-collision.ts +3 -7
- package/src/codex-auth-context.ts +11 -0
- package/src/codex-routing.ts +64 -2
- package/src/doctor.ts +173 -0
- package/src/oauth/anthropic.ts +2 -2
- package/src/oauth/google-antigravity.ts +4 -3
- package/src/oauth/kimi.ts +2 -2
- package/src/server/adapter-resolve.ts +43 -0
- package/src/server/gui-static.ts +98 -0
- package/src/server.ts +16 -125
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-Cy2tzg-5.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-DIBiVVC0.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ const MIN_THINKING_BUDGET = 1024;
|
|
|
40
40
|
const OUTPUT_HEADROOM = 8192;
|
|
41
41
|
/** Minimum visible-output room kept below `max_tokens` (so `max_tokens > budget_tokens` always holds). */
|
|
42
42
|
const OUTPUT_FLOOR = 4096;
|
|
43
|
-
const COMPAT_TOOL_PREFIX = "
|
|
43
|
+
const COMPAT_TOOL_PREFIX = "cx_";
|
|
44
44
|
|
|
45
45
|
/** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */
|
|
46
46
|
function reasoningBudget(effort: string): number {
|
|
@@ -172,7 +172,7 @@ function messagesToAnthropicFormat(
|
|
|
172
172
|
resultBlocks.push({
|
|
173
173
|
type: "tool_result",
|
|
174
174
|
tool_use_id: id,
|
|
175
|
-
content: "[
|
|
175
|
+
content: "[missing tool_result for this tool_use in history]",
|
|
176
176
|
is_error: true,
|
|
177
177
|
});
|
|
178
178
|
}
|
|
@@ -240,7 +240,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
|
|
|
240
240
|
if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
|
|
241
241
|
if (parsed.options.stopSequences) body.stop_sequences = parsed.options.stopSequences;
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
// `reasoning` is a Codex effort string; "none" is the disable sentinel (see parser.ts
|
|
244
|
+
// REASONING_EFFORTS). A bare truthy check would treat "none" as truthy and wrongly enable
|
|
245
|
+
// extended thinking (and strip temperature/top_p), so gate on a real, non-disable effort.
|
|
246
|
+
if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") {
|
|
244
247
|
// Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking +
|
|
245
248
|
// visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which
|
|
246
249
|
// 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens
|
|
@@ -270,6 +273,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
|
|
|
270
273
|
const headers: Record<string, string> = {
|
|
271
274
|
"Content-Type": "application/json",
|
|
272
275
|
"anthropic-version": "2023-06-01",
|
|
276
|
+
"Accept": parsed.stream ? "text/event-stream" : "application/json",
|
|
277
|
+
"User-Agent": "@anthropic-ai/sdk/0.74.0",
|
|
273
278
|
};
|
|
274
279
|
if (isOAuth) {
|
|
275
280
|
if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
@@ -20,6 +20,10 @@ export const CLAUDE_CODE_HEADERS: Record<string, string> = {
|
|
|
20
20
|
"X-Stainless-Runtime": "node",
|
|
21
21
|
"X-Stainless-Lang": "js",
|
|
22
22
|
"X-Stainless-Timeout": "600",
|
|
23
|
+
"X-Stainless-Arch": process.arch,
|
|
24
|
+
"X-Stainless-OS": process.platform,
|
|
25
|
+
"X-Stainless-Package-Version": "0.74.0",
|
|
26
|
+
"X-Stainless-Runtime-Version": process.version.slice(1),
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
/**
|
package/src/adapters/google.ts
CHANGED
|
@@ -19,7 +19,6 @@ import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google
|
|
|
19
19
|
import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
|
|
20
20
|
import { sanitizeGeminiToolParameters } from "./google-tool-schema";
|
|
21
21
|
import { neutralizeIdentity } from "./identity";
|
|
22
|
-
import { ANTIGRAVITY_GOOG_API_CLIENT_UA } from "./client-fingerprint";
|
|
23
22
|
import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
|
|
24
23
|
|
|
25
24
|
// Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
|
|
@@ -264,8 +263,6 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
|
|
|
264
263
|
request,
|
|
265
264
|
};
|
|
266
265
|
headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA;
|
|
267
|
-
// The Antigravity client library reports a secondary Google API client UA alongside the CLI UA.
|
|
268
|
-
headers["x-goog-api-client"] = ANTIGRAVITY_GOOG_API_CLIENT_UA;
|
|
269
266
|
if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
270
267
|
return { url, method: "POST", headers, body: JSON.stringify(envelope) };
|
|
271
268
|
}
|
|
@@ -8,9 +8,9 @@ let cachedFp: string | undefined;
|
|
|
8
8
|
export function fingerprint(): string {
|
|
9
9
|
if (cachedFp) return cachedFp;
|
|
10
10
|
try {
|
|
11
|
-
cachedFp = createHash("sha256").update(`${hostname()}-${userInfo().username}-kiro
|
|
11
|
+
cachedFp = createHash("sha256").update(`${hostname()}-${userInfo().username}-kiro`).digest("hex");
|
|
12
12
|
} catch {
|
|
13
|
-
cachedFp = createHash("sha256").update("default-kiro
|
|
13
|
+
cachedFp = createHash("sha256").update("default-kiro").digest("hex");
|
|
14
14
|
}
|
|
15
15
|
return cachedFp;
|
|
16
16
|
}
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { neutralizeIdentity } from "./identity";
|
|
|
33
33
|
const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
|
|
34
34
|
const SDK_VERSION = "1.0.27";
|
|
35
35
|
const NODE_VERSION = "22.21.1";
|
|
36
|
-
const KIRO_IDE_VERSION = "1.
|
|
36
|
+
const KIRO_IDE_VERSION = "1.0.0";
|
|
37
37
|
|
|
38
38
|
// Payload construction (conversationState)
|
|
39
39
|
interface KiroToolUse {
|
|
@@ -223,92 +223,127 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
223
223
|
let currentToolCallId = "";
|
|
224
224
|
let currentToolCallName = "";
|
|
225
225
|
let pendingUsage: OcxUsage | undefined;
|
|
226
|
+
// Track terminal signals so a socket EOF without any terminator can fail closed instead of
|
|
227
|
+
// being reported as a clean completion (silent truncation). A graceful close is either an
|
|
228
|
+
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
|
|
229
|
+
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
|
|
230
|
+
let sawFinish = false;
|
|
226
231
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
|
|
233
|
+
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
|
|
234
|
+
// Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
|
|
235
|
+
// must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
|
|
236
|
+
const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
237
|
+
if (!line.startsWith("data: ")) return "continue";
|
|
238
|
+
const payload = line.slice(6).trim();
|
|
239
|
+
if (payload === "[DONE]") {
|
|
240
|
+
if (currentToolCallId) {
|
|
241
|
+
yield { type: "tool_call_end" };
|
|
242
|
+
currentToolCallId = "";
|
|
243
|
+
}
|
|
244
|
+
yield { type: "done", usage: pendingUsage };
|
|
245
|
+
return "terminate";
|
|
246
|
+
}
|
|
235
247
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
yield { type: "done", usage: pendingUsage };
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
248
|
+
let chunk: Record<string, unknown>;
|
|
249
|
+
try {
|
|
250
|
+
chunk = JSON.parse(payload) as Record<string, unknown>;
|
|
251
|
+
} catch {
|
|
252
|
+
debugDroppedFrame("openai-chat", payload);
|
|
253
|
+
return "continue";
|
|
254
|
+
}
|
|
247
255
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
256
|
+
// A 200/OK chat-completions stream may carry an inline provider error envelope
|
|
257
|
+
// instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
|
|
258
|
+
// classified response.failed (bridge case "error") — never a truncated completion.
|
|
259
|
+
if (chunk.error) {
|
|
260
|
+
const err = chunk.error as { message?: string } | undefined;
|
|
261
|
+
if (currentToolCallId) yield { type: "tool_call_end" };
|
|
262
|
+
yield { type: "error", message: err?.message ?? "upstream error" };
|
|
263
|
+
return "terminate";
|
|
264
|
+
}
|
|
255
265
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
yield { type: "error", message: err?.message ?? "upstream error" };
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
266
|
+
if (chunk.usage) {
|
|
267
|
+
// Record usage but keep parsing: some providers send usage and the final content
|
|
268
|
+
// delta in the SAME chunk; a bail here would drop that content. The choices
|
|
269
|
+
// guard below no-ops a usage-only chunk.
|
|
270
|
+
pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
|
|
271
|
+
}
|
|
265
272
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
273
|
+
const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
|
|
274
|
+
if (!choices || choices.length === 0) return "continue";
|
|
275
|
+
// Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
|
|
276
|
+
// no delta) is a graceful close and must mark sawFinish even though we skip it below.
|
|
277
|
+
if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
|
|
278
|
+
sawFinish = true;
|
|
279
|
+
}
|
|
280
|
+
const delta = choices[0].delta;
|
|
281
|
+
if (delta) {
|
|
282
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
283
|
+
yield { type: "text_delta", text: delta.content };
|
|
284
|
+
}
|
|
272
285
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (!delta) continue;
|
|
286
|
+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
|
287
|
+
yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
|
|
288
|
+
}
|
|
277
289
|
|
|
278
|
-
|
|
279
|
-
|
|
290
|
+
const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
|
|
291
|
+
if (toolCalls) {
|
|
292
|
+
for (const tc of toolCalls) {
|
|
293
|
+
if (tc.id && tc.id !== currentToolCallId) {
|
|
294
|
+
if (currentToolCallId) yield { type: "tool_call_end" };
|
|
295
|
+
currentToolCallId = tc.id;
|
|
296
|
+
currentToolCallName = tc.function?.name ?? "";
|
|
297
|
+
yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
|
|
298
|
+
}
|
|
299
|
+
if (tc.function?.arguments) {
|
|
300
|
+
yield { type: "tool_call_delta", arguments: tc.function.arguments };
|
|
301
|
+
}
|
|
280
302
|
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
281
305
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
306
|
+
if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
|
|
307
|
+
yield { type: "tool_call_end" };
|
|
308
|
+
currentToolCallId = "";
|
|
309
|
+
}
|
|
310
|
+
return "continue";
|
|
311
|
+
};
|
|
285
312
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
currentToolCallId = tc.id;
|
|
292
|
-
currentToolCallName = tc.function?.name ?? "";
|
|
293
|
-
yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
|
|
294
|
-
}
|
|
295
|
-
if (tc.function?.arguments) {
|
|
296
|
-
yield { type: "tool_call_delta", arguments: tc.function.arguments };
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
313
|
+
try {
|
|
314
|
+
while (true) {
|
|
315
|
+
const { done, value } = await reader.read();
|
|
316
|
+
if (done) break;
|
|
317
|
+
buffer += decoder.decode(value, { stream: true });
|
|
300
318
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
319
|
+
const lines = buffer.split("\n");
|
|
320
|
+
buffer = lines.pop() ?? "";
|
|
321
|
+
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
if ((yield* handleDataLine(line)) === "terminate") return;
|
|
305
324
|
}
|
|
306
325
|
}
|
|
307
326
|
|
|
327
|
+
// Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
|
|
328
|
+
// and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
|
|
329
|
+
// the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
|
|
330
|
+
// content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
|
|
331
|
+
// complete stream loses its last frame and may be falsely failed below.
|
|
332
|
+
if (buffer.length > 0) {
|
|
333
|
+
if ((yield* handleDataLine(buffer)) === "terminate") return;
|
|
334
|
+
}
|
|
308
335
|
if (currentToolCallId) {
|
|
309
336
|
yield { type: "tool_call_end" };
|
|
310
337
|
}
|
|
311
|
-
// EOF
|
|
338
|
+
// Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
|
|
339
|
+
// a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
|
|
340
|
+
// at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
|
|
341
|
+
// closed so the bridge emits a classified response.failed rather than a silent truncation.
|
|
342
|
+
if (!sawFinish && pendingUsage === undefined) {
|
|
343
|
+
yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
|
|
312
347
|
yield { type: "done", usage: pendingUsage };
|
|
313
348
|
} finally {
|
|
314
349
|
reader.releaseLock();
|
|
@@ -42,6 +42,41 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
|
|
|
42
42
|
return changed ? { ...raw, input } : body;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Hosted (OpenAI-executed) tool types that specific native slugs reject at request time. Codex
|
|
47
|
+
* attaches these for app skills (e.g. `image_generation` for imagegen) regardless of the target
|
|
48
|
+
* model, and the passthrough path forwards the raw body untouched — so a slug that doesn't support
|
|
49
|
+
* the tool 400s (`Tool 'image_generation' is not supported with gpt-5.3-codex-spark.`). Each entry
|
|
50
|
+
* maps a model-slug matcher to the hosted tool types that must be stripped before forwarding.
|
|
51
|
+
* Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
|
|
52
|
+
*/
|
|
53
|
+
const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
|
|
54
|
+
{ match: model => model.includes("codex-spark"), tools: new Set(["image_generation"]) },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
58
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
|
|
63
|
+
* carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
|
|
64
|
+
* matches, keeping the common path allocation-free.
|
|
65
|
+
*/
|
|
66
|
+
function stripUnsupportedHostedTools(body: unknown): unknown {
|
|
67
|
+
if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
|
|
68
|
+
const model = typeof body.model === "string" ? body.model : "";
|
|
69
|
+
const unsupported = UNSUPPORTED_HOSTED_TOOLS.filter(e => e.match(model));
|
|
70
|
+
if (unsupported.length === 0) return body;
|
|
71
|
+
|
|
72
|
+
const tools = body.tools.filter(t => {
|
|
73
|
+
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
|
|
74
|
+
if (!type) return true;
|
|
75
|
+
return !unsupported.some(e => e.tools.has(type));
|
|
76
|
+
});
|
|
77
|
+
return tools.length === body.tools.length ? body : { ...body, tools };
|
|
78
|
+
}
|
|
79
|
+
|
|
45
80
|
export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
|
|
46
81
|
return {
|
|
47
82
|
name: "openai-responses",
|
|
@@ -82,7 +117,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
|
|
|
82
117
|
url,
|
|
83
118
|
method: "POST",
|
|
84
119
|
headers,
|
|
85
|
-
body: JSON.stringify(sanitizeReasoningInputContent(parsed._rawBody)),
|
|
120
|
+
body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(parsed._rawBody))),
|
|
86
121
|
};
|
|
87
122
|
},
|
|
88
123
|
|
package/src/cli-help.ts
CHANGED
|
@@ -44,6 +44,7 @@ const helpEntries: Record<string, HelpEntry> = {
|
|
|
44
44
|
sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." },
|
|
45
45
|
"sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
|
|
46
46
|
status: { usage: "ocx status", summary: "Check proxy server status." },
|
|
47
|
+
doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." },
|
|
47
48
|
login: { usage: "ocx login <provider>", summary: "OAuth or API-key login for a provider." },
|
|
48
49
|
logout: { usage: "ocx logout <provider>", summary: "Remove a stored provider login." },
|
|
49
50
|
gui: { usage: "ocx gui", summary: "Open the opencodex dashboard." },
|
|
@@ -80,6 +81,7 @@ Usage:
|
|
|
80
81
|
ocx sync Fetch models from providers and inject into Codex config
|
|
81
82
|
ocx sync-cache Refresh Codex's model cache from the active catalog
|
|
82
83
|
ocx status Check proxy server status
|
|
84
|
+
ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
|
|
83
85
|
ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
|
|
84
86
|
ocx logout <provider> Remove a stored OAuth login
|
|
85
87
|
ocx gui Open the opencodex dashboard
|
package/src/cli.ts
CHANGED
package/src/codex-auth-api.ts
CHANGED
|
@@ -262,6 +262,57 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
let primeInFlight: Promise<void> | null = null;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Best-effort prime of pool-account (and main) quota so the rotation engine has
|
|
269
|
+
* real usage scores instead of leaving every account at the unknown sentinel.
|
|
270
|
+
*
|
|
271
|
+
* Quota is otherwise populated only from live upstream headers (an idle pool
|
|
272
|
+
* account never serves traffic, so it never gets scored) or from the dashboard
|
|
273
|
+
* WHAM fetch (a CLI-only user never opens it). Without priming, every account
|
|
274
|
+
* stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
|
|
275
|
+
* and lazily before routing when the active account is unknown.
|
|
276
|
+
*
|
|
277
|
+
* Single-flight: concurrent callers share one pass instead of stampeding N WHAM
|
|
278
|
+
* fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
|
|
279
|
+
* cost, so the worst case is one WHAM call per account per TTL window. Failures
|
|
280
|
+
* are swallowed: a blocked WSL network must never crash startup or a request.
|
|
281
|
+
*/
|
|
282
|
+
export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): Promise<void> {
|
|
283
|
+
if (primeInFlight) return primeInFlight;
|
|
284
|
+
primeInFlight = (async () => {
|
|
285
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
286
|
+
const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
|
|
287
|
+
const stale = pool.filter(a => {
|
|
288
|
+
const q = getAccountQuota(a.id);
|
|
289
|
+
return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
|
|
290
|
+
});
|
|
291
|
+
const primeMain = !!readCodexTokens() && !getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
|
|
292
|
+
try {
|
|
293
|
+
await Promise.allSettled([
|
|
294
|
+
primeMain ? fetchMainAccountInfo(false) : Promise.resolve(),
|
|
295
|
+
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
296
|
+
if (!getCodexAccountCredential(a.id)) return;
|
|
297
|
+
await fetchPoolAccountQuota(a.id, false, a.plan);
|
|
298
|
+
}),
|
|
299
|
+
]);
|
|
300
|
+
} catch {
|
|
301
|
+
// Priming is best-effort; never propagate.
|
|
302
|
+
}
|
|
303
|
+
if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
|
|
304
|
+
console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
|
|
305
|
+
}
|
|
306
|
+
})().finally(() => { primeInFlight = null; });
|
|
307
|
+
return primeInFlight;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Test-only: drop any in-flight prime pass so a leaked single-flight promise
|
|
311
|
+
* from another suite cannot coalesce into the next prime. */
|
|
312
|
+
export function clearCodexQuotaPrimeState(): void {
|
|
313
|
+
primeInFlight = null;
|
|
314
|
+
}
|
|
315
|
+
|
|
265
316
|
export async function handleCodexAuthAPI(
|
|
266
317
|
req: Request,
|
|
267
318
|
url: URL,
|
|
@@ -37,18 +37,14 @@ function isWorkspacePlan(plan: string | undefined | null): boolean {
|
|
|
37
37
|
return !!plan && /team|business|enterprise|workspace|edu/i.test(plan);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// Main login and managed pool accounts are separate duplicate buckets.
|
|
41
|
+
// Inside the pool, personal and workspace subscriptions are also separate buckets.
|
|
42
|
+
// Within each pool bucket, keep the original ChatGPT account id + email collision guard.
|
|
42
43
|
export function checkAccountIdCollision(
|
|
43
44
|
chatgptAccountId: string,
|
|
44
45
|
email?: string | null,
|
|
45
46
|
plan?: string | null,
|
|
46
47
|
): { collision: true; reason: string } | { collision: false } {
|
|
47
|
-
const mainAccountId = getMainChatgptAccountId();
|
|
48
|
-
if (mainAccountId && mainAccountId === chatgptAccountId) {
|
|
49
|
-
return { collision: true, reason: "Account is already used by the main Codex login." };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
48
|
const candidateEmail = normalizedEmail(email);
|
|
53
49
|
const candidateWorkspace = isWorkspacePlan(plan);
|
|
54
50
|
for (const account of loadConfig().codexAccounts ?? []) {
|
|
@@ -8,6 +8,7 @@ import { markAccountNeedsReauth } from "./codex-account-runtime-state";
|
|
|
8
8
|
import { isCodexAccountUsable } from "./codex-account-usability";
|
|
9
9
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./codex-main-account";
|
|
10
10
|
import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
|
|
11
|
+
import { getAccountQuota } from "./codex-quota";
|
|
11
12
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
12
13
|
import { FORWARD_HEADERS } from "./adapters/openai-responses";
|
|
13
14
|
|
|
@@ -76,6 +77,16 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
|
|
|
76
77
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
77
78
|
const accountId = resolution.status === "selected" ? resolution.accountId : null;
|
|
78
79
|
if (!accountId) return { kind: "main", accountId: null };
|
|
80
|
+
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
81
|
+
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
82
|
+
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
83
|
+
// blocks the current request, and the helper's single-flight guard collapses
|
|
84
|
+
// repeated triggers into one pass.
|
|
85
|
+
if (!getAccountQuota(accountId)) {
|
|
86
|
+
import("./codex-auth-api")
|
|
87
|
+
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
}
|
|
79
90
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId);
|
|
80
91
|
if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
|
|
81
92
|
|
package/src/codex-routing.ts
CHANGED
|
@@ -12,6 +12,9 @@ type ThreadAffinityEntry = {
|
|
|
12
12
|
generation: number;
|
|
13
13
|
createdAt: number;
|
|
14
14
|
lastUsedAt: number;
|
|
15
|
+
// Last time the bound account's quota threshold was re-evaluated for this
|
|
16
|
+
// thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
|
|
17
|
+
lastReevalAt: number;
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
export type CodexThreadResolution =
|
|
@@ -32,6 +35,9 @@ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
|
32
35
|
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
33
36
|
export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
34
37
|
export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
|
|
38
|
+
// Min interval between quota threshold re-evaluations for a single bound thread.
|
|
39
|
+
// Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
|
|
40
|
+
export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
|
|
35
41
|
|
|
36
42
|
const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
37
43
|
|
|
@@ -200,6 +206,7 @@ function bindThreadAffinity(threadId: string, accountId: string, now: number): v
|
|
|
200
206
|
generation: record.generation,
|
|
201
207
|
createdAt: previous?.createdAt ?? now,
|
|
202
208
|
lastUsedAt: now,
|
|
209
|
+
lastReevalAt: now,
|
|
203
210
|
});
|
|
204
211
|
pruneLruThreadAffinities();
|
|
205
212
|
}
|
|
@@ -260,6 +267,20 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
|
260
267
|
saveConfig(config);
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
function isUnknownUsage(usage: number): boolean {
|
|
271
|
+
return usage >= CODEX_UNKNOWN_USAGE_SCORE;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Round-robin among eligible unknown-quota candidates. `getEligiblePoolAccounts`
|
|
275
|
+
// already returns a deterministic order (config order, main unshifted first) and
|
|
276
|
+
// excludes the active id, so taking the first eligible unknown is a stable rotation
|
|
277
|
+
// without any new per-account state.
|
|
278
|
+
function pickNextUnknownAccount(config: OcxConfig, active: string, now: number): string | null {
|
|
279
|
+
const eligible = getEligiblePoolAccounts(config, active, now)
|
|
280
|
+
.filter(id => isUnknownUsage(computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id))));
|
|
281
|
+
return eligible.length > 0 ? eligible[0]! : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
263
284
|
function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string {
|
|
264
285
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
265
286
|
if (threshold <= 0) return active;
|
|
@@ -267,8 +288,27 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s
|
|
|
267
288
|
const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
|
|
268
289
|
if (activeUsage < threshold) return active;
|
|
269
290
|
const best = pickLowerUsageAccount(config, active, activeUsage, now);
|
|
270
|
-
if (best !== active)
|
|
271
|
-
|
|
291
|
+
if (best !== active) {
|
|
292
|
+
setActiveCodexAccount(config, best);
|
|
293
|
+
return best;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Deadlock guard: active is over threshold but no candidate scored strictly
|
|
297
|
+
// lower. When the active itself is unknown, every candidate is likely unknown
|
|
298
|
+
// too (100 < 100 never fires), which pins the pool to one account whose real
|
|
299
|
+
// usage we cannot see (e.g. quota never primed on WSL). Rotate to the next
|
|
300
|
+
// eligible unknown so rotation is not stuck; known-but-saturated accounts are
|
|
301
|
+
// intentionally left alone so a genuinely hot pool stays visible.
|
|
302
|
+
if (isUnknownUsage(activeUsage)) {
|
|
303
|
+
const next = pickNextUnknownAccount(config, active, now);
|
|
304
|
+
if (next) {
|
|
305
|
+
console.warn(`[codex-routing] quota unknown for active "${active}"; rotating to "${next}" (all candidates unknown, threshold=${threshold})`);
|
|
306
|
+
setActiveCodexAccount(config, next);
|
|
307
|
+
return next;
|
|
308
|
+
}
|
|
309
|
+
console.warn(`[codex-routing] quota unknown for active "${active}" and no eligible rotation target; staying put`);
|
|
310
|
+
}
|
|
311
|
+
return active;
|
|
272
312
|
}
|
|
273
313
|
|
|
274
314
|
function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
|
|
@@ -314,6 +354,28 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
314
354
|
&& isCodexAccountSelectable(config, entry.accountId, now)
|
|
315
355
|
) {
|
|
316
356
|
entry.lastUsedAt = now;
|
|
357
|
+
// Periodic quota re-eval: a long-lived bound thread must still switch when
|
|
358
|
+
// it crosses autoSwitchThreshold and a strictly-cooler account exists.
|
|
359
|
+
// Without this the reuse branch returns before applyQuotaAutoSwitch and the
|
|
360
|
+
// thread stays pinned for the full idle TTL (the WSL "never switches" report).
|
|
361
|
+
if (now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
|
|
362
|
+
entry.lastReevalAt = now;
|
|
363
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
364
|
+
if (threshold > 0) {
|
|
365
|
+
const usage = computeCodexUsageScore(
|
|
366
|
+
getAccountQuota(entry.accountId),
|
|
367
|
+
getPoolAccountPlan(config, entry.accountId),
|
|
368
|
+
);
|
|
369
|
+
if (usage >= threshold) {
|
|
370
|
+
const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
|
|
371
|
+
if (best !== entry.accountId) {
|
|
372
|
+
setActiveCodexAccount(config, best);
|
|
373
|
+
bindThreadAffinity(threadId, best, now); // rebinds + resets clocks
|
|
374
|
+
return { status: "selected", accountId: best };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
317
379
|
return { status: "selected", accountId: entry.accountId };
|
|
318
380
|
}
|
|
319
381
|
threadAccountMap.delete(threadId);
|