@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.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/gui/dist/assets/index-CWXut3rG.js +115 -0
- package/gui/dist/assets/index-EdoPnm9_.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/devin.svg +49 -0
- package/gui/dist/provider-icons/omo.svg +42 -0
- package/package.json +3 -1
- package/src/AGENTS.md +1 -1
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
- package/src/adapters/command-code.ts +0 -1
- package/src/adapters/cursor/checkpoint-store.ts +37 -0
- package/src/adapters/cursor/live-transport.ts +74 -2
- package/src/adapters/cursor.ts +6 -0
- package/src/adapters/devin/cloud-direct/auth.ts +264 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
- package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
- package/src/adapters/devin/cloud-direct/index.ts +65 -0
- package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
- package/src/adapters/devin/cloud-direct/wire.ts +206 -0
- package/src/adapters/devin/live-models.ts +133 -0
- package/src/adapters/devin-cli/acp.ts +204 -0
- package/src/adapters/devin-cli/adapter.ts +345 -0
- package/src/adapters/devin-cli/binary.ts +69 -0
- package/src/adapters/devin-cli/models.ts +57 -0
- package/src/adapters/devin.ts +326 -0
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +31 -10
- package/src/adapters/openai-responses.ts +16 -1
- package/src/adapters/registry.ts +25 -1
- package/src/bridge.ts +8 -2
- package/src/claude/inbound-cache-stabilize.ts +130 -0
- package/src/claude/inbound.ts +45 -5
- package/src/cli/account-auth.ts +60 -1
- package/src/cli/account-extended.ts +23 -30
- package/src/cli/account.ts +2 -1
- package/src/cli/capabilities.ts +50 -8
- package/src/cli/config-command.ts +2 -2
- package/src/cli/dispatch.ts +14 -2
- package/src/cli/export-command.ts +11 -25
- package/src/cli/help.ts +2 -2
- package/src/cli/opencode.ts +5 -0
- package/src/cli/registry.ts +15 -4
- package/src/clients/config-export/cline.ts +71 -0
- package/src/clients/config-export/contracts.ts +10 -1
- package/src/clients/config-export/model-metadata.ts +33 -0
- package/src/clients/config-export/zcode.ts +23 -12
- package/src/clients/config-export.ts +156 -4
- package/src/codex/account-store.ts +7 -2
- package/src/codex/auth-context.ts +1 -1
- package/src/codex/catalog/effort.ts +4 -3
- package/src/codex/catalog/metadata.ts +8 -4
- package/src/codex/catalog/native-models.ts +24 -4
- package/src/codex/catalog/parsing.ts +19 -1
- package/src/codex/catalog/provider-fetch.ts +57 -0
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/context-compat.ts +97 -0
- package/src/codex/context-owner.ts +201 -0
- package/src/codex/history-provider.ts +161 -14
- package/src/codex/inject-coordination.ts +3 -2
- package/src/codex/inject.ts +128 -9
- package/src/codex/pool-rotation.ts +8 -292
- package/src/codex/quota.ts +41 -12
- package/src/codex/retired-model-migration.ts +41 -0
- package/src/codex/routing.ts +327 -22
- package/src/codex/warmup.ts +2 -2
- package/src/combos/failover.ts +5 -0
- package/src/combos/resolve.ts +11 -15
- package/src/config.ts +46 -14
- package/src/generated/compatibility-version.json +293 -113
- package/src/grok/grpc-web.ts +120 -0
- package/src/grok/reset-coupon-ledger.ts +139 -0
- package/src/grok/reset-coupons.ts +278 -0
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/cline-document.ts +73 -0
- package/src/integrations/cline-io.ts +149 -0
- package/src/integrations/cline-transaction.ts +42 -0
- package/src/integrations/config-io.ts +21 -2
- package/src/integrations/journal.ts +43 -1
- package/src/integrations/omp-yaml-source.ts +123 -4
- package/src/integrations/ownership-policy.ts +7 -0
- package/src/integrations/ownership.ts +36 -0
- package/src/integrations/registry.ts +27 -0
- package/src/integrations/state.ts +5 -2
- package/src/integrations/store.ts +6 -0
- package/src/integrations/writer.ts +53 -9
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/abort.ts +36 -0
- package/src/lib/local-destinations.ts +1 -1
- package/src/lib/upstream-retry.ts +36 -1
- package/src/oauth/account-quota-rank.ts +11 -0
- package/src/oauth/callback-server.ts +10 -5
- package/src/oauth/devin/api-base.ts +63 -0
- package/src/oauth/devin/login.ts +1 -0
- package/src/oauth/devin/register-user.ts +186 -0
- package/src/oauth/devin/types.ts +71 -0
- package/src/oauth/devin-cli.ts +149 -0
- package/src/oauth/devin.ts +170 -0
- package/src/oauth/generic-account-failover.ts +169 -1
- package/src/oauth/index.ts +20 -1
- package/src/oauth/login-cli.ts +19 -5
- package/src/oauth/pool-kernel.ts +321 -0
- package/src/oauth/pool-settings-capability.ts +127 -9
- package/src/oauth/store.ts +7 -3
- package/src/oauth/token-guardian.ts +1 -1
- package/src/providers/codebuddy-models.ts +0 -3
- package/src/providers/command-code-efforts.ts +23 -4
- package/src/providers/default-aliases.ts +4 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/devin-cli-authmode-migration.ts +57 -0
- package/src/providers/key-failover.ts +175 -0
- package/src/providers/model-rename-startup.ts +21 -1
- package/src/providers/qoder-models.ts +0 -1
- package/src/providers/quota-key-accounts.ts +50 -0
- package/src/providers/quota-routing-cache.ts +65 -6
- package/src/providers/quota-types.ts +7 -0
- package/src/providers/quota.ts +113 -30
- package/src/providers/registry.ts +237 -77
- package/src/providers/stale-context-window-migration.ts +92 -0
- package/src/providers/zai-responses-migration.ts +45 -0
- package/src/quota/reset-observer.ts +2 -1
- package/src/quota/reset-seen-store.ts +9 -1
- package/src/remote-control/crypto.ts +442 -0
- package/src/remote-control/host.ts +175 -0
- package/src/remote-control/index.ts +100 -0
- package/src/remote-control/protocol.ts +200 -0
- package/src/remote-control/relay.ts +162 -0
- package/src/remote-control/workspace-agent-protocol.ts +246 -0
- package/src/remote-control/workspace-rpc-framing.ts +128 -0
- package/src/remote-control/workspace-tools.ts +237 -0
- package/src/remote-control/workspace-utf8.ts +24 -0
- package/src/responses/custom-tool-compat.ts +23 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/adapter-resolve.ts +6 -2
- package/src/server/auth-cors.ts +71 -6
- package/src/server/chat-completions.ts +5 -3
- package/src/server/chat-native.ts +9 -0
- package/src/server/claude-messages.ts +25 -30
- package/src/server/context-history.ts +207 -0
- package/src/server/images.ts +28 -1
- package/src/server/index.ts +42 -22
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/config-routes.ts +3 -3
- package/src/server/management/grok-coupon-routes.ts +287 -0
- package/src/server/management/integration-routes.ts +6 -1
- package/src/server/management/oauth-account-routes.ts +124 -7
- package/src/server/management/provider-routes.ts +77 -2
- package/src/server/management/route-registry.ts +6 -1
- package/src/server/management-api.ts +7 -0
- package/src/server/request-log.ts +4 -0
- package/src/server/responses/codex-ws-exchange.ts +90 -14
- package/src/server/responses/codex-ws-wire.ts +51 -1
- package/src/server/responses/compact.ts +8 -1
- package/src/server/responses/core.ts +188 -22
- package/src/server/responses/ws-upstream.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +261 -12
- package/src/server/zai-responses-startup.ts +21 -0
- package/src/types/config.ts +33 -3
- package/src/types/provider.ts +54 -4
- package/src/types/request.ts +1 -1
- package/src/types/tools.ts +36 -6
- package/src/update/job.ts +33 -11
- package/src/vision/plan.ts +40 -37
- package/src/web-search/index.ts +1 -0
- package/gui/dist/assets/index-BoBRSehJ.css +0 -1
- package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
|
@@ -255,6 +255,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
255
255
|
const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
|
|
256
256
|
clearProviderQuotaCache();
|
|
257
257
|
clearAccountQuotaCache(provider);
|
|
258
|
+
if (provider === "devin") {
|
|
259
|
+
// The cached user_jwt's payload contains the api_key, and the catalog is
|
|
260
|
+
// keyed by that key. Without this they outlive the credential in process
|
|
261
|
+
// memory until the JWT's own ~24 minute expiry.
|
|
262
|
+
const { clearCachedUserJwt, clearCachedCatalog } = await import("../../adapters/devin/cloud-direct");
|
|
263
|
+
clearCachedUserJwt();
|
|
264
|
+
clearCachedCatalog();
|
|
265
|
+
}
|
|
258
266
|
return jsonResponse({ success: true });
|
|
259
267
|
}
|
|
260
268
|
|
|
@@ -331,6 +339,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
331
339
|
if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
|
|
332
340
|
const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover");
|
|
333
341
|
forgetGenericFailoverRoster(provider);
|
|
342
|
+
// Seed the rotation cursor on the operator's pick, or a sticky round-robin ring hands the
|
|
343
|
+
// very next dispatch back to whatever the pool had chosen. forgetGenericFailoverRoster
|
|
344
|
+
// only drops the presence count; it has never touched the cursor. Same defect the Codex
|
|
345
|
+
// side carries resetCodexRoutingForManualSelection for.
|
|
346
|
+
const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel");
|
|
347
|
+
seedPoolRotationAccount(genericPoolKey(provider), body.accountId);
|
|
334
348
|
if (provider === "anthropic") {
|
|
335
349
|
const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
|
|
336
350
|
resetAnthropicRoutingForManualSelection(body.accountId);
|
|
@@ -344,6 +358,91 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
344
358
|
return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
|
|
345
359
|
}
|
|
346
360
|
|
|
361
|
+
// The unified pool-settings contract (#695 wp5c). The three legacy paths keep working and
|
|
362
|
+
// keep their own shapes -- goldens pin them -- but this is the one an operator or a dashboard
|
|
363
|
+
// should read, because it answers with the same keys for every kind and DECLARES which of
|
|
364
|
+
// them that kind honours.
|
|
365
|
+
if (url.pathname === "/api/pool/settings" && (req.method === "GET" || req.method === "PUT" || req.method === "PATCH")) {
|
|
366
|
+
const {
|
|
367
|
+
poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit,
|
|
368
|
+
unifiedPoolSettingsDto,
|
|
369
|
+
} = await import("../../oauth/pool-settings-capability");
|
|
370
|
+
const rawBody = req.method === "GET" ? {} : await readManagementJsonBodyOr(req, {});
|
|
371
|
+
if (req.method !== "GET" && !isPlainRecord(rawBody)) {
|
|
372
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
373
|
+
}
|
|
374
|
+
const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown };
|
|
375
|
+
const provider = req.method === "GET"
|
|
376
|
+
? (url.searchParams.get("provider") ?? "").trim().toLowerCase()
|
|
377
|
+
: (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : "");
|
|
378
|
+
const kind = provider ? poolSettingsCapability(provider, config.providers?.[provider]) : null;
|
|
379
|
+
if (!provider || !kind) {
|
|
380
|
+
return jsonResponse({ error: "pool settings are only available for the codex, anthropic and generic OAuth pools" }, 400);
|
|
381
|
+
}
|
|
382
|
+
// Validated by the SHARED parsers before any kind-specific write, so a bad strategy or
|
|
383
|
+
// sticky limit is refused identically whichever pool is addressed.
|
|
384
|
+
let strategy: string | undefined;
|
|
385
|
+
if (fields.strategy !== undefined) {
|
|
386
|
+
const parsed = parseGenericPoolStrategy(fields.strategy);
|
|
387
|
+
if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
|
|
388
|
+
strategy = parsed;
|
|
389
|
+
}
|
|
390
|
+
let stickyLimit: number | undefined;
|
|
391
|
+
if (fields.stickyLimit !== undefined) {
|
|
392
|
+
const parsed = parseGenericStickyLimit(fields.stickyLimit);
|
|
393
|
+
if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
|
|
394
|
+
stickyLimit = parsed;
|
|
395
|
+
}
|
|
396
|
+
let autoSwitchThreshold: number | undefined;
|
|
397
|
+
if (fields.autoSwitchThreshold !== undefined) {
|
|
398
|
+
const parsed = parseGenericAutoSwitchThreshold(fields.autoSwitchThreshold);
|
|
399
|
+
if (parsed === null) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
|
|
400
|
+
autoSwitchThreshold = parsed;
|
|
401
|
+
}
|
|
402
|
+
if (fields.quotaWindow !== undefined && kind !== "anthropic") {
|
|
403
|
+
return jsonResponse({ error: "quotaWindow is only part of the anthropic pool contract" }, 400);
|
|
404
|
+
}
|
|
405
|
+
let quotaWindow: string | undefined;
|
|
406
|
+
if (fields.quotaWindow !== undefined) {
|
|
407
|
+
const parsed = parseAccountPoolQuotaWindow(fields.quotaWindow);
|
|
408
|
+
if (parsed === null) return jsonResponse({ error: "quotaWindow must be one of: five-hour, weekly, max-utilization" }, 400);
|
|
409
|
+
quotaWindow = parsed;
|
|
410
|
+
}
|
|
411
|
+
if (fields.enabled !== undefined) {
|
|
412
|
+
if (kind === "codex") return jsonResponse({ error: "enabled is not part of the codex pool contract" }, 400);
|
|
413
|
+
if (typeof fields.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (req.method !== "GET") {
|
|
417
|
+
if (kind === "codex") {
|
|
418
|
+
if (strategy !== undefined) config.accountPoolStrategy = strategy as never;
|
|
419
|
+
if (stickyLimit !== undefined) config.accountPoolStickyLimit = stickyLimit;
|
|
420
|
+
if (autoSwitchThreshold !== undefined) config.autoSwitchThreshold = autoSwitchThreshold;
|
|
421
|
+
} else if (kind === "anthropic") {
|
|
422
|
+
const pool = { ...(config.anthropicAccountPool ?? {}) };
|
|
423
|
+
if (fields.enabled !== undefined) pool.enabled = fields.enabled as boolean;
|
|
424
|
+
if (strategy !== undefined) pool.strategy = strategy as never;
|
|
425
|
+
if (stickyLimit !== undefined) pool.stickyLimit = stickyLimit;
|
|
426
|
+
if (autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = autoSwitchThreshold;
|
|
427
|
+
if (quotaWindow !== undefined) pool.quotaWindow = quotaWindow as never;
|
|
428
|
+
config.anthropicAccountPool = pool;
|
|
429
|
+
} else {
|
|
430
|
+
const prov = config.providers[provider]!;
|
|
431
|
+
const next = { ...(prov.oauthAccountFailover ?? {}) };
|
|
432
|
+
if (fields.enabled !== undefined) next.enabled = fields.enabled as boolean;
|
|
433
|
+
if (strategy !== undefined) next.strategy = strategy as never;
|
|
434
|
+
if (stickyLimit !== undefined) next.stickyLimit = stickyLimit;
|
|
435
|
+
if (autoSwitchThreshold !== undefined) next.autoSwitchThreshold = autoSwitchThreshold;
|
|
436
|
+
if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
|
|
437
|
+
else delete prov.oauthAccountFailover;
|
|
438
|
+
}
|
|
439
|
+
saveConfigPreservingClaudeCode(config);
|
|
440
|
+
reconcileLiveStateStores();
|
|
441
|
+
}
|
|
442
|
+
return jsonResponse(unifiedPoolSettingsDto(config, provider, kind));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
347
446
|
// Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown.
|
|
348
447
|
if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
|
|
349
448
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
@@ -357,7 +456,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
357
456
|
if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
|
|
358
457
|
return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
|
|
359
458
|
}
|
|
360
|
-
return jsonResponse(genericPoolSettingsDto(provider, prov));
|
|
459
|
+
return jsonResponse(genericPoolSettingsDto(provider, prov, config.pool?.kernel === true));
|
|
361
460
|
}
|
|
362
461
|
const pool = config.anthropicAccountPool ?? {};
|
|
363
462
|
return jsonResponse({
|
|
@@ -387,13 +486,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
387
486
|
if (provider !== "anthropic") {
|
|
388
487
|
const {
|
|
389
488
|
poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold,
|
|
489
|
+
parseGenericStickyLimit,
|
|
390
490
|
} = await import("../../oauth/pool-settings-capability");
|
|
391
491
|
const prov = config.providers[provider];
|
|
392
492
|
if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
|
|
393
493
|
return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
|
|
394
494
|
}
|
|
395
|
-
if (body.
|
|
396
|
-
return jsonResponse({ error: "
|
|
495
|
+
if (body.quotaWindow !== undefined) {
|
|
496
|
+
return jsonResponse({ error: "quotaWindow is not part of the generic pool contract yet" }, 400);
|
|
397
497
|
}
|
|
398
498
|
const next = { ...(prov.oauthAccountFailover ?? {}) };
|
|
399
499
|
if (body.enabled !== undefined) {
|
|
@@ -416,10 +516,18 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
416
516
|
next.autoSwitchThreshold = parsed;
|
|
417
517
|
}
|
|
418
518
|
}
|
|
519
|
+
if (body.stickyLimit !== undefined) {
|
|
520
|
+
if (body.stickyLimit === null) delete next.stickyLimit;
|
|
521
|
+
else {
|
|
522
|
+
const parsed = parseGenericStickyLimit(body.stickyLimit);
|
|
523
|
+
if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
|
|
524
|
+
next.stickyLimit = parsed;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
419
527
|
if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
|
|
420
528
|
else delete prov.oauthAccountFailover;
|
|
421
529
|
saveConfigPreservingClaudeCode(config);
|
|
422
|
-
return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov) });
|
|
530
|
+
return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov, config.pool?.kernel === true) });
|
|
423
531
|
}
|
|
424
532
|
let enabled = config.anthropicAccountPool?.enabled === true;
|
|
425
533
|
if (body.enabled !== undefined) {
|
|
@@ -623,8 +731,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
623
731
|
clearModelCache(name);
|
|
624
732
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
625
733
|
clearProviderQuotaCache();
|
|
626
|
-
const { clearKeyCooldowns } = await import("../../providers/key-failover");
|
|
734
|
+
const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover");
|
|
627
735
|
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
736
|
+
// ...and the rotation cursor with it. A cursor that predates the operator's choice would
|
|
737
|
+
// hand the next proactive pick straight back to whichever key the pool had reached.
|
|
738
|
+
forgetApiKeyRotationCursor(name);
|
|
628
739
|
return jsonResponse({ ok: true, id: result.id }, 201);
|
|
629
740
|
}
|
|
630
741
|
// Opt-in OS keychain storage (#1221): move the active key and pool into the OS credential
|
|
@@ -667,8 +778,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
667
778
|
clearModelCache(name);
|
|
668
779
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
669
780
|
clearProviderQuotaCache();
|
|
670
|
-
const { clearKeyCooldowns } = await import("../../providers/key-failover");
|
|
781
|
+
const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover");
|
|
671
782
|
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
783
|
+
// ...and the rotation cursor with it. A cursor that predates the operator's choice would
|
|
784
|
+
// hand the next proactive pick straight back to whichever key the pool had reached.
|
|
785
|
+
forgetApiKeyRotationCursor(name);
|
|
672
786
|
return jsonResponse({ ok: true, name, activeId: body.id });
|
|
673
787
|
}
|
|
674
788
|
if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
|
|
@@ -696,8 +810,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
696
810
|
clearModelCache(name);
|
|
697
811
|
const { clearProviderQuotaCache } = await import("../../providers/quota");
|
|
698
812
|
clearProviderQuotaCache();
|
|
699
|
-
const { clearKeyCooldowns } = await import("../../providers/key-failover");
|
|
813
|
+
const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover");
|
|
700
814
|
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
815
|
+
// ...and the rotation cursor with it. A cursor that predates the operator's choice would
|
|
816
|
+
// hand the next proactive pick straight back to whichever key the pool had reached.
|
|
817
|
+
forgetApiKeyRotationCursor(name);
|
|
701
818
|
return jsonResponse({ ok: true });
|
|
702
819
|
}
|
|
703
820
|
|
|
@@ -57,7 +57,10 @@ import {
|
|
|
57
57
|
import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
|
|
58
58
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
59
59
|
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
60
|
-
import {
|
|
60
|
+
import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache";
|
|
61
|
+
import { PROVIDER_QUOTA_MAX_AGE_MS, type ProviderRoutingQuota } from "../../providers/quota-types";
|
|
62
|
+
import { cachedProviderQuotaIsExhausted } from "../../combos/resolve";
|
|
63
|
+
import { clearKeyCooldowns, forgetApiKeyRotationCursor } from "../../providers/key-failover";
|
|
61
64
|
import { providerRequestPacingStatus } from "../../providers/request-pacing";
|
|
62
65
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
63
66
|
import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
|
|
@@ -112,6 +115,7 @@ import {
|
|
|
112
115
|
XAI_RESPONSES_DEFAULT_VERSION,
|
|
113
116
|
xaiResponsesOptInState,
|
|
114
117
|
} from "../../providers/xai-responses-opt-in";
|
|
118
|
+
import { ZAI_PROVIDER_ID } from "../../providers/zai-responses-migration";
|
|
115
119
|
import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
|
|
116
120
|
|
|
117
121
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
@@ -573,6 +577,19 @@ function applyProviderPatchFields(
|
|
|
573
577
|
}
|
|
574
578
|
touched = true;
|
|
575
579
|
}
|
|
580
|
+
if (Object.hasOwn(rawBody, "noJsonSchemaModels")) {
|
|
581
|
+
const value = rawBody.noJsonSchemaModels;
|
|
582
|
+
if (value === null) {
|
|
583
|
+
delete next.noJsonSchemaModels;
|
|
584
|
+
} else {
|
|
585
|
+
const error = nonBlankStringArrayConfigError(value, "noJsonSchemaModels");
|
|
586
|
+
if (error) return { error };
|
|
587
|
+
const models = normalizeNonBlankStringArray(value as string[]);
|
|
588
|
+
if (models.length > 0) next.noJsonSchemaModels = models;
|
|
589
|
+
else delete next.noJsonSchemaModels;
|
|
590
|
+
}
|
|
591
|
+
touched = true;
|
|
592
|
+
}
|
|
576
593
|
if (Object.hasOwn(rawBody, "retainModels")) {
|
|
577
594
|
const value = rawBody.retainModels;
|
|
578
595
|
if (value === null) {
|
|
@@ -689,12 +706,54 @@ function canonicalOpenAiBudgetPatchError(
|
|
|
689
706
|
?? providerEmptyToolOutputConfigError("openai", applied.next);
|
|
690
707
|
}
|
|
691
708
|
|
|
709
|
+
function providerRoutingQuota(config: OcxConfig, name: string, now: number): ProviderRoutingQuota {
|
|
710
|
+
const provider = hasOwnProvider(config.providers, name) ? config.providers[name] : undefined;
|
|
711
|
+
const quota = getCachedProviderRoutingQuota(name, provider, now);
|
|
712
|
+
if (!quota || !Number.isFinite(quota.updatedAt) || quota.updatedAt < 0 || quota.updatedAt > now
|
|
713
|
+
|| now >= quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS) return { state: "unknown" };
|
|
714
|
+
|
|
715
|
+
// Removing search/MCP windows may leave only a timestamp. That is not inference evidence.
|
|
716
|
+
const percentages = [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent,
|
|
717
|
+
...(quota.customWindows ?? []).map(window => window.percent)];
|
|
718
|
+
const hasPercentage = percentages.some(value => typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
719
|
+
const credits = quota.creditsUsd;
|
|
720
|
+
const hasCredits = credits !== undefined && Number.isFinite(credits.percent)
|
|
721
|
+
&& credits.percent >= 0 && Number.isFinite(credits.remaining);
|
|
722
|
+
if (!hasPercentage && !hasCredits) return { state: "unknown" };
|
|
723
|
+
|
|
724
|
+
const state = cachedProviderQuotaIsExhausted(quota, now) ? "exhausted" : "available";
|
|
725
|
+
let validUntil = quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS;
|
|
726
|
+
if (state === "exhausted") {
|
|
727
|
+
const resets = [quota.fiveHourResetAt, quota.weeklyResetAt, quota.monthlyResetAt,
|
|
728
|
+
...(quota.customWindows ?? []).map(window => window.resetAt)]
|
|
729
|
+
.filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)
|
|
730
|
+
&& reset > now && reset < validUntil)
|
|
731
|
+
.sort((left, right) => left - right);
|
|
732
|
+
// Reuse dispatch's predicate: another exhausted window or USD cap may still block.
|
|
733
|
+
for (const reset of resets) {
|
|
734
|
+
if (!cachedProviderQuotaIsExhausted(quota, reset)) {
|
|
735
|
+
validUntil = reset;
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return { state, updatedAt: quota.updatedAt, validUntil };
|
|
741
|
+
}
|
|
742
|
+
|
|
692
743
|
export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
693
744
|
const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
694
745
|
|
|
695
746
|
if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
|
|
696
747
|
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
697
|
-
|
|
748
|
+
const snapshot = await fetchProviderQuotaReports(config, forceRefresh);
|
|
749
|
+
const now = Date.now();
|
|
750
|
+
return jsonResponse({
|
|
751
|
+
...snapshot,
|
|
752
|
+
reports: snapshot.reports.map(report => ({
|
|
753
|
+
...report,
|
|
754
|
+
routingQuota: providerRoutingQuota(config, report.provider, now),
|
|
755
|
+
})),
|
|
756
|
+
});
|
|
698
757
|
}
|
|
699
758
|
|
|
700
759
|
if (url.pathname === "/api/provider-request-pacing" && req.method === "GET") {
|
|
@@ -730,6 +789,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
730
789
|
modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
|
|
731
790
|
modelSupportsServiceTier: p.modelSupportsServiceTier,
|
|
732
791
|
noStructuredOutputModels: p.noStructuredOutputModels,
|
|
792
|
+
noJsonSchemaModels: p.noJsonSchemaModels,
|
|
733
793
|
retainModels: p.retainModels,
|
|
734
794
|
omitReasoningEffortWithToolsModels: p.omitReasoningEffortWithToolsModels,
|
|
735
795
|
upstreamHttpVersion: p.upstreamHttpVersion,
|
|
@@ -816,6 +876,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
816
876
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
817
877
|
clearAccountQuotaCache(name);
|
|
818
878
|
clearKeyCooldowns(name);
|
|
879
|
+
// The rotation cursor describes a pool this edit just changed; keeping it would let a
|
|
880
|
+
// stale position steer the next proactive pick.
|
|
881
|
+
forgetApiKeyRotationCursor(name);
|
|
819
882
|
clearModelCache(name);
|
|
820
883
|
if (name === "openai") (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
821
884
|
const catalogRefresh = await convergeCodexCatalog();
|
|
@@ -915,6 +978,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
915
978
|
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
916
979
|
clearAccountQuotaCache();
|
|
917
980
|
clearKeyCooldowns();
|
|
981
|
+
// Cursors too, for the same reason the cooldown clear above takes no name: this PUT
|
|
982
|
+
// replaces the whole roster, and a cursor that outlives it still names a real id, so
|
|
983
|
+
// round-robin would resume after the pre-edit position instead of the new roster head.
|
|
984
|
+
forgetApiKeyRotationCursor();
|
|
918
985
|
clearModelCache();
|
|
919
986
|
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
920
987
|
const catalogRefresh = await convergeCodexCatalog();
|
|
@@ -1061,6 +1128,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1061
1128
|
prov.xaiResponsesDefaultVersion = latest.xaiResponsesDefaultVersion;
|
|
1062
1129
|
}
|
|
1063
1130
|
}
|
|
1131
|
+
// Same reason for the Z.AI marker: the provider form never carries it, and losing it on an
|
|
1132
|
+
// unrelated edit would let the one-time wire rewrite run a second time.
|
|
1133
|
+
if (name === ZAI_PROVIDER_ID) {
|
|
1134
|
+
const latest = config.providers[name];
|
|
1135
|
+
if (latest?.zaiResponsesDefaultVersion !== undefined) {
|
|
1136
|
+
prov.zaiResponsesDefaultVersion = latest.zaiResponsesDefaultVersion;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1064
1139
|
// Reapply pins to the latest live row after DNS/import awaits, then validate the
|
|
1065
1140
|
// complete draft before adopting any provider/default state.
|
|
1066
1141
|
const latest = config.providers[name];
|
|
@@ -107,7 +107,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
107
107
|
{ method: "PUT", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: true },
|
|
108
108
|
{ method: "PUT", path: "/api/codex-auth/auto-switch", module: "codex/auth-api", mutates: true },
|
|
109
109
|
{ method: "PUT", path: "/api/codex-auth/failover", module: "codex/auth-api", mutates: true },
|
|
110
|
-
{ method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true },
|
|
110
|
+
{ method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true, exempt: { reason: "compatibility-alias", why: "Superseded by PUT /api/pool/settings, which the CLI now drives. Kept working for existing clients and pinned by exact-body goldens in tests/server/account-pool-management-api.test.ts; no CLI verb targets it any more." } },
|
|
111
111
|
// codex/native-profile-api
|
|
112
112
|
{ method: "GET", path: "/api/native-main-profiles", module: "codex/native-profile-api", mutates: false },
|
|
113
113
|
{ method: "GET", path: "/api/native-main-profiles/doctor", module: "codex/native-profile-api", mutates: false },
|
|
@@ -131,6 +131,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
131
131
|
{ method: "GET", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: false },
|
|
132
132
|
{ method: "POST", path: "/api/claude-desktop/apply", module: "server/management/agent-settings-routes", mutates: true },
|
|
133
133
|
{ method: "POST", path: "/api/grok/apply", module: "server/management/agent-settings-routes", mutates: true },
|
|
134
|
+
{ method: "GET", path: "/api/grok/reset-coupons", module: "server/management/grok-coupon-routes", mutates: false },
|
|
135
|
+
{ method: "POST", path: "/api/grok/reset-coupons/consume", module: "server/management/grok-coupon-routes", mutates: true },
|
|
134
136
|
{ method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true },
|
|
135
137
|
{ method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true },
|
|
136
138
|
{ method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true },
|
|
@@ -261,6 +263,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
261
263
|
{ method: "GET", path: "/api/oauth/accounts", module: "server/management/oauth-account-routes", mutates: false },
|
|
262
264
|
{ method: "GET", path: "/api/accounts/events", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "gui-invalidation", why: "Dashboard selection invalidation stream; CLI account commands read the authoritative account/key resources directly rather than subscribing to browser refresh notifications." } },
|
|
263
265
|
{ method: "GET", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: false },
|
|
266
|
+
{ method: "GET", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: false },
|
|
267
|
+
{ method: "PUT", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: true },
|
|
268
|
+
{ method: "PATCH", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: true },
|
|
264
269
|
{ method: "GET", path: "/api/oauth/providers", module: "server/management/oauth-account-routes", mutates: false },
|
|
265
270
|
{ method: "GET", path: "/api/oauth/status", module: "server/management/oauth-account-routes", mutates: false },
|
|
266
271
|
{ method: "GET", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: false },
|
|
@@ -143,6 +143,12 @@ async function handleQuotaResetRoutesOnDemand(ctx: ManagementContext): Promise<R
|
|
|
143
143
|
return handleQuotaResetRoutes(ctx);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
async function handleGrokCouponRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
147
|
+
if (!pathInManagementNamespace(ctx.url.pathname, "/api/grok/reset-coupons", true)) return null;
|
|
148
|
+
const { handleGrokCouponRoutes } = await import("./management/grok-coupon-routes");
|
|
149
|
+
return handleGrokCouponRoutes(ctx);
|
|
150
|
+
}
|
|
151
|
+
|
|
146
152
|
export async function handleManagementAPI(
|
|
147
153
|
req: Request,
|
|
148
154
|
url: URL,
|
|
@@ -241,6 +247,7 @@ export async function handleManagementAPI(
|
|
|
241
247
|
?? (await handleLogsUsageRoutes(ctx))
|
|
242
248
|
?? (await handleRequestHistoryRoutes(ctx))
|
|
243
249
|
?? (await handleQuotaResetRoutesOnDemand(ctx))
|
|
250
|
+
?? (await handleGrokCouponRoutesOnDemand(ctx))
|
|
244
251
|
?? (await handleRoutingAnalyticsRoutes(ctx))
|
|
245
252
|
?? (await handleRoutingProfileRoutesOnDemand(ctx))
|
|
246
253
|
?? (await handleProviderRoutes(ctx))
|
|
@@ -50,6 +50,7 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/
|
|
|
50
50
|
import { capEstimateAtContextWindow } from "../lib/token-estimate";
|
|
51
51
|
import { inferCursorContextWindow } from "../adapters/cursor/discovery";
|
|
52
52
|
import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
|
|
53
|
+
import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models";
|
|
53
54
|
import { modelRecordValue } from "../reasoning-effort";
|
|
54
55
|
|
|
55
56
|
export interface RequestLogContext {
|
|
@@ -1190,6 +1191,9 @@ function contextWindowForModel(adapter: string, modelId: string | undefined): nu
|
|
|
1190
1191
|
if (adapter === "cursor" || adapter.startsWith("cursor-")) {
|
|
1191
1192
|
return inferCursorContextWindow(modelId);
|
|
1192
1193
|
}
|
|
1194
|
+
if (adapter === "devin") {
|
|
1195
|
+
return modelRecordValue(DEVIN_MODEL_CONTEXT_WINDOWS, modelId);
|
|
1196
|
+
}
|
|
1193
1197
|
return undefined;
|
|
1194
1198
|
}
|
|
1195
1199
|
|
|
@@ -4,9 +4,9 @@ import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata"
|
|
|
4
4
|
import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request";
|
|
5
5
|
import { CodexWsCorrelation } from "./codex-ws-correlation";
|
|
6
6
|
import type { CodexWsSession } from "./codex-ws-session";
|
|
7
|
-
import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
|
|
7
|
+
import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
|
|
8
8
|
MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
|
|
9
|
-
codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire";
|
|
9
|
+
codexWsFailureDetail, codexWsPreResponseFailure, type CodexWsFailureStage } from "./codex-ws-wire";
|
|
10
10
|
|
|
11
11
|
interface ExchangeOptions {
|
|
12
12
|
session: CodexWsSession;
|
|
@@ -101,6 +101,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
101
101
|
let upstreamFrames = 0;
|
|
102
102
|
let controlFrames = 0;
|
|
103
103
|
let relayedEvents = 0;
|
|
104
|
+
let pings = 0;
|
|
105
|
+
let pongs = 0;
|
|
104
106
|
let sentAt: number | null = null;
|
|
105
107
|
let firstFrameAt: number | null = null;
|
|
106
108
|
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
@@ -108,7 +110,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
108
110
|
const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
|
|
109
111
|
const correlation = session.retainable ? new CodexWsCorrelation(session.reused, id => session.hasCompleted(id)) : null;
|
|
110
112
|
let detachOwner = () => {};
|
|
111
|
-
|
|
113
|
+
// Liveness while waiting for the first response event (metadata path only): the
|
|
114
|
+
// silence timer is re-armed by every inbound frame or pong; the pinger runs on a fixed
|
|
115
|
+
// interval so a peer that answers pings can never trip the silence bound while alive.
|
|
116
|
+
let silenceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
+
let pingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
112
118
|
const stream = new ReadableStream<Uint8Array>({
|
|
113
119
|
start(c) { controller = c; },
|
|
114
120
|
cancel() {
|
|
@@ -121,7 +127,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
121
127
|
|
|
122
128
|
const cleanup = () => {
|
|
123
129
|
clearTimeout(upgradeTimer);
|
|
124
|
-
clearTimeout(
|
|
130
|
+
clearTimeout(silenceTimer);
|
|
131
|
+
clearTimeout(pingTimer);
|
|
125
132
|
signal?.removeEventListener("abort", onAbort);
|
|
126
133
|
metadata?.finish();
|
|
127
134
|
correlation?.finish();
|
|
@@ -130,6 +137,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
130
137
|
ws.removeEventListener("message", onMessage);
|
|
131
138
|
ws.removeEventListener("close", onClose);
|
|
132
139
|
ws.removeEventListener("error", onError);
|
|
140
|
+
ws.removeEventListener("pong", onPong);
|
|
133
141
|
};
|
|
134
142
|
|
|
135
143
|
/**
|
|
@@ -145,12 +153,15 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
145
153
|
relayedEvents,
|
|
146
154
|
firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null,
|
|
147
155
|
elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null,
|
|
156
|
+
pings,
|
|
157
|
+
pongs,
|
|
148
158
|
});
|
|
149
159
|
|
|
150
160
|
const commitResponse = () => {
|
|
151
161
|
if (responseCommitted) return;
|
|
152
162
|
responseCommitted = true;
|
|
153
|
-
clearTimeout(
|
|
163
|
+
clearTimeout(silenceTimer);
|
|
164
|
+
clearTimeout(pingTimer);
|
|
154
165
|
const responseHeaders = metadata?.snapshot() ?? new Headers();
|
|
155
166
|
responseHeaders.set("content-type", "text/event-stream; charset=utf-8");
|
|
156
167
|
const response = new Response(stream, { status: 200, headers: responseHeaders });
|
|
@@ -159,17 +170,61 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
159
170
|
resolve(response);
|
|
160
171
|
};
|
|
161
172
|
|
|
162
|
-
const failStream = (error: unknown) => {
|
|
173
|
+
const failStream = (error: unknown, status: 502 | 504 = 502) => {
|
|
163
174
|
if (terminal) return;
|
|
164
175
|
terminal = true;
|
|
165
|
-
|
|
166
|
-
|
|
176
|
+
if (sent && !responseCommitted && metadata) {
|
|
177
|
+
// Nothing has been promised to the client yet, so the honest answer is a gateway
|
|
178
|
+
// status, not a 200 whose body then fails. The frame may already be executing
|
|
179
|
+
// upstream: the response is marked non-replayable so no layer of this process sends
|
|
180
|
+
// it again, and the client applies its own retry policy as it would on the direct
|
|
181
|
+
// path. Same settle order as a refused create: snapshot, detach, close, dispose.
|
|
182
|
+
const prelude = metadata.snapshot();
|
|
183
|
+
// Claim the commit slot so no later path can resolve a second, 200 Response.
|
|
184
|
+
responseCommitted = true;
|
|
185
|
+
cleanup();
|
|
186
|
+
try { controller?.close(); } catch { /* unused stream already closed */ }
|
|
187
|
+
session.dispose();
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
+
resolve(codexWsPreResponseFailure(status, message, prelude));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// A response is already flowing (or this transport has no metadata channel and
|
|
193
|
+
// committed at send). Settle as a body failure, never a fetch rejection/5xx that the
|
|
194
|
+
// pre-stream wrapper could resend.
|
|
167
195
|
if (sent) commitResponse();
|
|
168
196
|
cleanup();
|
|
169
197
|
try { controller?.error(typeof error === "string" ? new Error(error) : error); } catch { /* stream already done */ }
|
|
170
198
|
session.dispose();
|
|
171
199
|
};
|
|
172
200
|
|
|
201
|
+
/** (Re)start the silence bound; every inbound frame or pong is proof of life. */
|
|
202
|
+
const armSilence = () => {
|
|
203
|
+
clearTimeout(silenceTimer);
|
|
204
|
+
if (responseCommitted || terminal) return;
|
|
205
|
+
silenceTimer = setTimeout(
|
|
206
|
+
() => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`, 504),
|
|
207
|
+
CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
/** Ping on a fixed interval until the response starts; a socket without ping() is never pinged. */
|
|
211
|
+
const schedulePing = () => {
|
|
212
|
+
const ping = (ws as WebSocket & { ping?: (data?: string) => void }).ping;
|
|
213
|
+
if (typeof ping !== "function" || responseCommitted || terminal) return;
|
|
214
|
+
pingTimer = setTimeout(() => {
|
|
215
|
+
if (responseCommitted || terminal) return;
|
|
216
|
+
try { ping.call(ws); } catch { return; }
|
|
217
|
+
pings += 1;
|
|
218
|
+
// ping() may close the socket synchronously and settle the exchange; re-check.
|
|
219
|
+
if (!terminal) schedulePing();
|
|
220
|
+
}, CODEX_WS_LIVENESS_PING_INTERVAL_MS);
|
|
221
|
+
};
|
|
222
|
+
const onPong = () => {
|
|
223
|
+
if (terminal) return;
|
|
224
|
+
pongs += 1;
|
|
225
|
+
if (!responseCommitted) armSilence();
|
|
226
|
+
};
|
|
227
|
+
|
|
173
228
|
const upgradeTimer = setTimeout(() => {
|
|
174
229
|
if (opened || settledPreOpen) return;
|
|
175
230
|
settledPreOpen = true;
|
|
@@ -188,6 +243,25 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
188
243
|
reject(reason);
|
|
189
244
|
return;
|
|
190
245
|
}
|
|
246
|
+
if (!responseCommitted && metadata) {
|
|
247
|
+
// Sent, unacknowledged. The proxy's own connect deadline is an origin-silence
|
|
248
|
+
// verdict and settles like one; a caller abort is the caller's decision, so the
|
|
249
|
+
// exchange rejects with that reason and disposing the socket cancels the turn.
|
|
250
|
+
// Both arrive on the same composite signal (fetchWithHeaderTimeout joins the
|
|
251
|
+
// caller's controller with its own), so the reason is the only discriminator: the
|
|
252
|
+
// deadline aborts with a TimeoutError DOMException, and every caller abort in this
|
|
253
|
+
// process (upstream.abort() in core.ts) carries the default AbortError. A future
|
|
254
|
+
// proxy-side deadline that aborts with TimeoutError would still be honestly a 504.
|
|
255
|
+
if ((reason as { name?: unknown } | null)?.name === "TimeoutError") {
|
|
256
|
+
failStream(`codex websocket response did not start before the connect deadline${codexWsFailureDetail(failureStage())}`, 504);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
terminal = true;
|
|
260
|
+
cleanup();
|
|
261
|
+
session.dispose();
|
|
262
|
+
reject(reason);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
191
265
|
failStream(reason);
|
|
192
266
|
};
|
|
193
267
|
const onAbort = () => cancelExchange(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
@@ -208,6 +282,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
208
282
|
ws.removeEventListener("message", onMessage);
|
|
209
283
|
ws.removeEventListener("close", onClose);
|
|
210
284
|
ws.removeEventListener("error", onError);
|
|
285
|
+
ws.removeEventListener("pong", onPong);
|
|
211
286
|
session.dispose();
|
|
212
287
|
reject(error);
|
|
213
288
|
return;
|
|
@@ -236,16 +311,15 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
236
311
|
}
|
|
237
312
|
if (!metadata) commitResponse();
|
|
238
313
|
else if (!responseCommitted && !terminal) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
|
|
242
|
-
);
|
|
314
|
+
armSilence();
|
|
315
|
+
schedulePing();
|
|
243
316
|
}
|
|
244
317
|
};
|
|
245
318
|
|
|
246
319
|
const onMessage = (event: MessageEvent) => {
|
|
247
320
|
if (!controller || terminal) return;
|
|
248
321
|
received = true;
|
|
322
|
+
if (!responseCommitted) armSilence();
|
|
249
323
|
upstreamFrames += 1;
|
|
250
324
|
if (firstFrameAt === null) firstFrameAt = Date.now();
|
|
251
325
|
const text = typeof event.data === "string" ? event.data : "";
|
|
@@ -288,8 +362,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
288
362
|
if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
|
|
289
363
|
if (!controlFrame) {
|
|
290
364
|
try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; }
|
|
291
|
-
// Correlation must run first: a reused socket's foreign-stream error
|
|
292
|
-
//
|
|
365
|
+
// Correlation must run first: a reused socket's foreign-stream error settles as a
|
|
366
|
+
// non-replayable 502 above, never as the refused-create 4xx projection below, which
|
|
367
|
+
// is the one status family that could authorize an account replay.
|
|
293
368
|
if (metadata && sent && !responseCommitted && type === "error") {
|
|
294
369
|
let rejection: Response | null;
|
|
295
370
|
try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); }
|
|
@@ -366,6 +441,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
366
441
|
ws.addEventListener("message", onMessage);
|
|
367
442
|
ws.addEventListener("close", onClose);
|
|
368
443
|
ws.addEventListener("error", onError);
|
|
444
|
+
ws.addEventListener("pong", onPong);
|
|
369
445
|
if (signal?.aborted) onAbort();
|
|
370
446
|
else if (session.opened) onOpen();
|
|
371
447
|
});
|