@bitkyc08/opencodex 2.72.0 → 2.73.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.
Files changed (79) hide show
  1. package/gui/dist/assets/{App-Cf-WYstE.js → App-mHGa7dE1.js} +15 -15
  2. package/gui/dist/assets/{Tray-DkM0mULy.js → Tray-BxY_RYZg.js} +1 -1
  3. package/gui/dist/assets/{index-PWrwOfio.js → index-ur_BPgK1.js} +12 -12
  4. package/gui/dist/assets/{quota-summary-D-FKtrgr.js → quota-summary-D1Vq7N9H.js} +1 -1
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/cursor/native-exec.ts +28 -10
  8. package/src/adapters/devin/live-models.ts +5 -0
  9. package/src/adapters/kiro/reasoning.ts +2 -0
  10. package/src/adapters/openai-chat/messages.ts +4 -1
  11. package/src/adapters/openai-responses/passthrough.ts +12 -1
  12. package/src/adapters/openai-responses/reasoning.ts +15 -0
  13. package/src/bridge/internal.ts +12 -0
  14. package/src/cli/account-anthropic-threshold.ts +41 -0
  15. package/src/cli/account-api.ts +2 -0
  16. package/src/cli/account-extended.ts +32 -21
  17. package/src/cli/account.ts +1 -0
  18. package/src/cli/capabilities.ts +8 -4
  19. package/src/cli/index.ts +1 -1
  20. package/src/codex/catalog/build-entries.ts +6 -5
  21. package/src/codex/catalog/derive-entry.ts +3 -4
  22. package/src/codex/catalog/gather-capture.ts +2 -0
  23. package/src/codex/catalog/metadata.ts +6 -0
  24. package/src/codex/catalog/native-models.ts +17 -3
  25. package/src/codex/catalog/parsing.ts +4 -1
  26. package/src/codex/catalog/pinned-models.ts +3 -1
  27. package/src/codex/catalog/provider-models.ts +22 -2
  28. package/src/codex/data/roster-pinned-models.json +178 -1
  29. package/src/codex/model-entitlements.ts +1 -1
  30. package/src/codex/native-profile-store.ts +2 -1
  31. package/src/combos/resolve.ts +16 -1
  32. package/src/config/subagent-models.ts +30 -5
  33. package/src/generated/compatibility-version.json +95 -67
  34. package/src/generated/model-metadata.ts +4 -4
  35. package/src/lib/account-selection-events.ts +28 -0
  36. package/src/lib/errors.ts +7 -5
  37. package/src/lib/keyring-native.ts +132 -0
  38. package/src/lib/provider-outbound.ts +13 -0
  39. package/src/lib/retry-delay.ts +10 -0
  40. package/src/oauth/anthropic-account-threshold.ts +13 -0
  41. package/src/oauth/anthropic-routing.ts +223 -52
  42. package/src/oauth/index.ts +4 -2
  43. package/src/oauth/store.ts +45 -3
  44. package/src/oauth/types.ts +2 -0
  45. package/src/providers/api-key-resolve.ts +2 -4
  46. package/src/providers/codebuddy-models.ts +6 -0
  47. package/src/providers/kiro-models.ts +4 -0
  48. package/src/providers/label.ts +20 -1
  49. package/src/providers/quota/account-cache.ts +2 -1
  50. package/src/providers/quota/anthropic-cooldown-recovery.ts +118 -0
  51. package/src/providers/quota/vendor-probes-oauth.ts +84 -57
  52. package/src/providers/quota.ts +45 -46
  53. package/src/providers/registry/entries-core.ts +3 -1
  54. package/src/providers/registry/entries-extended.ts +14 -1
  55. package/src/providers/registry/model-seeds.ts +7 -1
  56. package/src/server/claude-messages.ts +84 -27
  57. package/src/server/management/anthropic-account-threshold.ts +27 -0
  58. package/src/server/management/oauth-account-routes.ts +17 -4
  59. package/src/server/management/route-registry.ts +1 -0
  60. package/src/server/messages-native-oauth.ts +20 -21
  61. package/src/server/messages-native.ts +39 -14
  62. package/src/server/relay-eager.ts +5 -2
  63. package/src/server/relay.ts +268 -82
  64. package/src/server/responses/adapter-continuation.ts +6 -0
  65. package/src/server/responses/adapter-delivery.ts +25 -7
  66. package/src/server/responses/adapter-dispatch.ts +31 -0
  67. package/src/server/responses/buffered-sse-json.ts +345 -0
  68. package/src/server/responses/core-codex-account.ts +4 -1
  69. package/src/server/responses/core-combo.ts +28 -0
  70. package/src/server/responses/core-lifetime.ts +4 -0
  71. package/src/server/responses/passthrough-delivery.ts +305 -10
  72. package/src/server/responses/passthrough-dispatch.ts +60 -25
  73. package/src/server/responses/passthrough-error.ts +3 -1
  74. package/src/server/responses/request-transport.ts +22 -5
  75. package/src/server/responses/terminal-error-redaction.ts +53 -0
  76. package/src/service/windows-taskxml.ts +20 -6
  77. package/src/service-manager-probe.ts +115 -6
  78. package/src/types/wire.ts +7 -0
  79. package/src/usage/expected-prices.ts +11 -0
@@ -1156,6 +1156,17 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1156
1156
  modelContextWindows: { "gpt-5.6-terra": 1_050_000 },
1157
1157
  modelMaxOutputTokens: { "gpt-5.6-terra": 128_000 },
1158
1158
  modelInputModalities: { "gpt-5.6-terra": ["text", "image"] },
1159
+ // Per-model wires from `tokenlab.accepted_request_formats` (GET /v1/models/{id}, 2026-09-30).
1160
+ // The provider stays on Chat, the released and end-to-end verified path, and every model
1161
+ // accepts it. Models that also declare Responses use it for Codex (Responses inbound) only,
1162
+ // so Chat and Anthropic clients skip a translation hop; an explicit modelAdapters entry wins.
1163
+ // Claude ids ride Anthropic Messages through the endpoint-bound pin in src/types/wire.ts.
1164
+ // gemini-3.8-flash declares Chat + Gemini native and stays on Chat. No delivery-policy
1165
+ // header is sent: the API key's own policy stays authoritative.
1166
+ modelWireDefaults: Object.fromEntries([
1167
+ "gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "grok-4.7",
1168
+ "deepseek-v4.1-flash", "deepseek-v4-pro", "kimi-k3", "glm-5.3",
1169
+ ].map(id => [id, { wire: "openai-responses", inbound: ["responses"] }])),
1159
1170
  modelDiscovery: {
1160
1171
  path: "models",
1161
1172
  query: { category: "chat" },
@@ -1342,7 +1353,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1342
1353
  featured: false,
1343
1354
  dashboardUrl: "https://github.com/settings/copilot",
1344
1355
  liveModels: true,
1345
- models: ["gpt-4o", "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4", "gemini-2.5-pro", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-sol", "gpt-6-luna"],
1356
+ models: ["gpt-4o", "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4", "gemini-2.5-pro", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"],
1346
1357
  defaultModel: "gpt-4o",
1347
1358
  // Copilot fronts a mixed-wire catalog: these models reject /chat/completions for
1348
1359
  // real Codex-agent traffic (function tools + reasoning), so every inbound wire
@@ -1362,6 +1373,8 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1362
1373
  // 260923 preemptive: GPT-6 Sol/Luna ride Responses like every GPT-5.6/6 row above.
1363
1374
  "gpt-6-sol": "openai-responses",
1364
1375
  "gpt-6-luna": "openai-responses",
1376
+ // GPT-6.1 Sol: GA in Copilot 2026-09-29 (github.blog changelog); rides Responses like GPT-6 Sol.
1377
+ "gpt-6.1-sol": "openai-responses",
1365
1378
  "grok-4.5": "openai-responses",
1366
1379
  "grok-4.6": "openai-responses",
1367
1380
  "mai-code-1.1-flash": "openai-responses",
@@ -164,8 +164,12 @@ export const OPENAI_API_GPT56_REASONING_EFFORTS = ["low", "medium", "high", "xhi
164
164
  * GPT-6 Sol and Luna on the OpenAI API (released 2026-09-22,
165
165
  * https://developers.openai.com/api/docs/changelog). Added 2026-09-23 ahead of live discovery; the
166
166
  * API window is not published yet, so the rows mirror gpt-6-astra's 1,050,000 / 922,000 API seed.
167
+ *
168
+ * GPT-6.1 Sol (released 2026-09-29) publishes the same numbers on its own model page:
169
+ * 1,050,000 context, 922,000 max input, 128,000 max output, efforts low..max
170
+ * (https://developers.openai.com/api/docs/models/gpt-6.1-sol, checked 2026-09-30).
167
171
  */
168
- export const OPENAI_GPT6_MODELS = ["gpt-6-sol", "gpt-6-luna"];
172
+ export const OPENAI_GPT6_MODELS = ["gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"];
169
173
  /*
170
174
  * Meta Model API (https://api.meta.ai/v1) — published ladder, deliberately NOT the
171
175
  * house set. dev.meta.ai/docs/reasoning lists "none", "minimal", "low", "medium",
@@ -254,6 +258,8 @@ export const OPENROUTER_GPT56_CONTEXT_WINDOWS = {
254
258
  // 260923 preemptive: GPT-6 Sol/Luna ahead of OpenRouter's own listing; same window as GPT-5.6.
255
259
  "openai/gpt-6-sol": OPENROUTER_GPT56_CONTEXT_WINDOW,
256
260
  "openai/gpt-6-luna": OPENROUTER_GPT56_CONTEXT_WINDOW,
261
+ // Live /api/v1/models on 2026-09-30: context_length 1,050,000, max_completion_tokens 128,000.
262
+ "openai/gpt-6.1-sol": OPENROUTER_GPT56_CONTEXT_WINDOW,
257
263
  };
258
264
 
259
265
  /**
@@ -14,6 +14,7 @@ import {
14
14
  resolveAdmissionModelScope,
15
15
  } from "./admission-model-scope";
16
16
  import { jsonUtf8Bytes } from "../lib/json-byte-size";
17
+ import { redactSecretString } from "../lib/redact";
17
18
  import { sseFieldValue } from "../lib/sse-decoder";
18
19
  import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard";
19
20
  import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
@@ -295,46 +296,55 @@ export interface PassthroughBodyGuard {
295
296
  }
296
297
 
297
298
  type PassthroughCloseReason = "terminal" | "client_cancel" | "body_stall" | "body_overflow";
299
+ type PassthroughFinalizeMeta = { closeReason: PassthroughCloseReason; terminalStatus?: "failed" };
298
300
 
299
301
  /**
300
302
  * Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal),
301
303
  * bounding body occupancy: idle (silence-only, timed ONLY while a reader.read() is
302
304
  * pending so downstream backpressure never counts as upstream inactivity) and a
303
- * cumulative byte cap. On stall/overflow it appends a protocol-compatible Anthropic
304
- * `event: error` terminal frame after a blank-line boundary, closes, and cancels the
305
- * upstream reader — never a total-wall-clock bound (slow-but-alive streams live).
305
+ * cumulative byte cap. On stall/overflow, or when the upstream read fails after the
306
+ * headers went out, it appends a protocol-compatible Anthropic `event: error` terminal
307
+ * frame after a blank-line boundary, closes, and cancels the upstream reader — never a
308
+ * total-wall-clock bound (slow-but-alive streams live).
306
309
  * Exported for deterministic unit tests.
307
310
  */
308
311
  export function tapAnthropicSseForLog(
309
312
  upstream: ReadableStream<Uint8Array>,
310
313
  logCtx: RequestLogContext,
311
- finalize: (status: number, meta: { closeReason: PassthroughCloseReason }) => void,
314
+ finalize: (status: number, meta: PassthroughFinalizeMeta) => void,
312
315
  guard?: PassthroughBodyGuard,
313
316
  ): ReadableStream<Uint8Array> {
314
317
  const decoder = new TextDecoder();
315
318
  const encoder = new TextEncoder();
316
319
  let buffer = "";
317
320
  let usageAcc: Rec = {};
321
+ // message_stop or an upstream error event: the turn's own terminal already went through.
322
+ let terminalSeen = false;
323
+ const inspectFrame = (frame: string) => {
324
+ const dataLine = frame
325
+ .split("\n")
326
+ .map(l => sseFieldValue(l, "data"))
327
+ .filter((v): v is string => v !== null)
328
+ .join("");
329
+ if (!dataLine) return;
330
+ let data: unknown;
331
+ try { data = JSON.parse(dataLine); } catch { return; }
332
+ if (!isRec(data)) return;
333
+ if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
334
+ usageAcc = { ...usageAcc, ...data.message.usage };
335
+ } else if (data.type === "message_delta" && isRec(data.usage)) {
336
+ usageAcc = { ...usageAcc, ...data.usage };
337
+ } else if (data.type === "message_stop" || data.type === "error") {
338
+ terminalSeen = true;
339
+ }
340
+ };
318
341
  const inspect = (chunk: Uint8Array) => {
319
342
  buffer += decoder.decode(chunk, { stream: true });
320
343
  let sep: number;
321
344
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
322
345
  const frame = buffer.slice(0, sep);
323
346
  buffer = buffer.slice(sep + 2);
324
- const dataLine = frame
325
- .split("\n")
326
- .map(l => sseFieldValue(l, "data"))
327
- .filter((v): v is string => v !== null)
328
- .join("");
329
- if (!dataLine) continue;
330
- let data: unknown;
331
- try { data = JSON.parse(dataLine); } catch { continue; }
332
- if (!isRec(data)) continue;
333
- if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
334
- usageAcc = { ...usageAcc, ...data.message.usage };
335
- } else if (data.type === "message_delta" && isRec(data.usage)) {
336
- usageAcc = { ...usageAcc, ...data.usage };
337
- }
347
+ inspectFrame(frame);
338
348
  }
339
349
  };
340
350
  const reader = upstream.getReader();
@@ -345,13 +355,7 @@ export function tapAnthropicSseForLog(
345
355
  const recordUsage = () => {
346
356
  logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
347
357
  };
348
- const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => {
349
- if (settled) return;
350
- settled = true;
351
- idle.cancel();
352
- detachAbort();
353
- recordUsage();
354
- finalize(200, { closeReason });
358
+ const closeWithErrorFrame = (errType: string, message: string) => {
355
359
  const payload = JSON.stringify({ type: "error", error: { type: errType, message } });
356
360
  try {
357
361
  // Leading blank line terminates any partial SSE block so the frame parses cleanly
@@ -359,6 +363,15 @@ export function tapAnthropicSseForLog(
359
363
  tapController?.enqueue(encoder.encode(`\n\nevent: error\ndata: ${payload}\n\n`));
360
364
  tapController?.close();
361
365
  } catch { /* client already torn down */ }
366
+ };
367
+ const failBody = (closeReason: "body_stall" | "body_overflow", errType: string, message: string) => {
368
+ if (settled) return;
369
+ settled = true;
370
+ idle.cancel();
371
+ detachAbort();
372
+ recordUsage();
373
+ finalize(200, { closeReason });
374
+ closeWithErrorFrame(errType, message);
362
375
  reader.cancel(new DOMException(message, closeReason === "body_stall" ? "TimeoutError" : "QuotaExceededError")).catch(() => {});
363
376
  };
364
377
  const idle = idleDeadline(guard?.stallMs ?? 0, () => {
@@ -427,12 +440,56 @@ export function tapAnthropicSseForLog(
427
440
  controller.enqueue(value);
428
441
  } catch (err) {
429
442
  if (settled) return;
443
+ // Bun can settle a fetch body read before it dispatches the abort listeners
444
+ // (consumeForInspection in relay.ts): read the signal itself before calling this
445
+ // rejection an upstream failure.
446
+ if (guard?.reqSignal?.aborted) {
447
+ onClientAbort();
448
+ return;
449
+ }
430
450
  settled = true;
431
451
  idle.cancel();
432
452
  detachAbort();
453
+ // A read error can follow the last SSE block before its blank-line delimiter. Count
454
+ // that block before deciding how the turn ended, as the Responses relay does.
455
+ const terminalBeforeTail = terminalSeen;
456
+ const tail = buffer + decoder.decode();
457
+ buffer = "";
458
+ if (tail) inspectFrame(tail);
433
459
  recordUsage();
434
- finalize(200, { closeReason: "terminal" });
435
- try { controller.error(err); } catch { /* torn down */ }
460
+ if (isTranslatorBudgetExceededError(err)) {
461
+ // A local cap, not an upstream failure: the non-streaming native Messages fold
462
+ // maps this error to a 413 itself, so it still errors the stream.
463
+ finalize(200, { closeReason: "terminal" });
464
+ try { controller.error(err); } catch { /* torn down */ }
465
+ return;
466
+ }
467
+ if (terminalSeen) {
468
+ // Only the transport trailer was lost; the client already has the turn's terminal.
469
+ // The Responses relay likewise reports a read error only without a seen terminal.
470
+ finalize(200, { closeReason: "terminal" });
471
+ try {
472
+ // An SSE parser drops an event that EOF cuts off before its blank line, so restore
473
+ // the delimiter when the terminal was only found in that unterminated tail.
474
+ if (!terminalBeforeTail) controller.enqueue(encoder.encode("\n\n"));
475
+ controller.close();
476
+ } catch { /* torn down */ }
477
+ reader.cancel(err).catch(() => {});
478
+ return;
479
+ }
480
+ // The upstream read failed after the 200 went out (a mid-stream socket reset). Log it
481
+ // the way the Responses relay does (onReadError): a truncated body is a failed turn,
482
+ // not a completed one. The client gets the same Anthropic error terminal as a stall,
483
+ // instead of a connection reset — or, on some Bun releases, a bare EOF that reads as
484
+ // a finished message.
485
+ const message = redactSecretString(`anthropic passthrough upstream stream failed: ${err instanceof Error ? err.message : String(err)}`);
486
+ logCtx.transportPhase = "mid_stream";
487
+ logCtx.terminalSource = "synthetic";
488
+ logCtx.upstreamError = message.slice(0, 500);
489
+ if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true;
490
+ finalize(502, { terminalStatus: "failed", closeReason: "terminal" });
491
+ closeWithErrorFrame("api_error", message);
492
+ reader.cancel(err).catch(() => {});
436
493
  }
437
494
  },
438
495
  cancel(reason) {
@@ -0,0 +1,27 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { effectiveAnthropicAccountThreshold, parseAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold";
3
+ import { OAUTH_PROVIDERS } from "../../oauth";
4
+ import { setAnthropicAccountThreshold } from "../../oauth/store";
5
+ import { jsonResponse } from "../auth-cors";
6
+ import { readManagementJsonBodyOr } from "./body";
7
+
8
+ /** Account-owned policy writes share the auth-store lock, not a config/auth split transaction. */
9
+ export async function handleAnthropicAccountThreshold(req: Request, config: OcxConfig): Promise<Response> {
10
+ const body = await readManagementJsonBodyOr(req, {});
11
+ if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "body must be an object" }, 400);
12
+ const fields = body as Record<string, unknown>;
13
+ const definition = OAUTH_PROVIDERS.anthropic;
14
+ const effectiveProvider = config.providers.anthropic
15
+ ?? definition?.resolveProviderConfig?.(config) ?? definition?.providerConfig;
16
+ if (fields.provider !== "anthropic" || effectiveProvider?.authMode !== "oauth") {
17
+ return jsonResponse({ error: "account threshold requires Anthropic OAuth" }, 400);
18
+ }
19
+ if (typeof fields.accountId !== "string" || !fields.accountId.trim()) return jsonResponse({ error: "missing accountId" }, 400);
20
+ const threshold = parseAnthropicAccountThreshold(fields.threshold);
21
+ if (fields.threshold !== null && threshold === null) return jsonResponse({ error: "threshold must be an integer 0-100 or null" }, 400);
22
+ if (!await setAnthropicAccountThreshold(fields.accountId, threshold)) return jsonResponse({ error: "account not found" }, 404);
23
+ return jsonResponse({ ok: true, provider: "anthropic", accountId: fields.accountId,
24
+ autoSwitchThresholdOverride: threshold,
25
+ effectiveAutoSwitchThreshold: effectiveAnthropicAccountThreshold(config, { autoSwitchThresholdOverride: threshold ?? undefined }),
26
+ autoSwitchThreshold: effectiveAnthropicAccountThreshold(config) });
27
+ }
@@ -1,4 +1,6 @@
1
1
  import { parseAnthropicModelRoutes, readAnthropicModelRoutes } from "../../oauth/anthropic-model-routes";
2
+ import { effectiveAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold";
3
+ import { handleAnthropicAccountThreshold } from "./anthropic-account-threshold";
2
4
  import { randomBytes, randomUUID } from "node:crypto";
3
5
  import { readFileSync } from "node:fs";
4
6
  import type { CatalogModel } from "../../codex/catalog";
@@ -396,7 +398,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
396
398
  const { isGenericFailoverProvider, kiroAutoSelection } = await import("../../oauth/generic-account-failover");
397
399
  const effectiveProvider = genericOAuthProviderConfig(provider, config);
398
400
  const supportsPause = effectiveProvider !== undefined
399
- && isGenericFailoverProvider(provider, effectiveProvider);
401
+ && (provider === "anthropic" && effectiveProvider.authMode === "oauth"
402
+ || isGenericFailoverProvider(provider, effectiveProvider));
400
403
  const {
401
404
  oauthAccountHealthFields,
402
405
  projectOAuthAccountHealth,
@@ -417,6 +420,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
417
420
  });
418
421
  return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
419
422
  ...(supportsPause ? { paused: full?.paused === true } : {}),
423
+ ...(provider === "anthropic" && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null,
424
+ effectiveAutoSwitchThreshold: effectiveAnthropicAccountThreshold(config, full),
425
+ autoSwitchThreshold: effectiveAnthropicAccountThreshold(config) } : {}),
420
426
  ...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) };
421
427
  }),
422
428
  };
@@ -492,6 +498,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
492
498
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
493
499
  }
494
500
 
501
+ if (url.pathname === "/api/oauth/accounts/auto-switch" && req.method === "PUT") return handleAnthropicAccountThreshold(req, config);
495
502
  if (url.pathname === "/api/oauth/accounts/pause" && req.method === "PUT") {
496
503
  const body = await readManagementJsonBodyOr(req, {});
497
504
  if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400);
@@ -504,7 +511,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
504
511
 
505
512
  const { isGenericFailoverProvider } = await import("../../oauth/generic-account-failover");
506
513
  const effectiveProvider = genericOAuthProviderConfig(provider, config);
507
- if (!effectiveProvider || !isGenericFailoverProvider(provider, effectiveProvider)) {
514
+ if (!effectiveProvider || !(provider === "anthropic" && effectiveProvider.authMode === "oauth"
515
+ || isGenericFailoverProvider(provider, effectiveProvider))) {
508
516
  return jsonResponse({ error: "account pause is not supported for this OAuth provider" }, 400);
509
517
  }
510
518
 
@@ -513,8 +521,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
513
521
  if (result.status === "not-found") return jsonResponse({ error: "account not found" }, 404);
514
522
 
515
523
  if (result.activeAccountChanged) {
516
- const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel");
517
- seedPoolRotationAccount(genericPoolKey(provider), result.activeAccountId);
524
+ if (provider === "anthropic") {
525
+ const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
526
+ resetAnthropicRoutingForManualSelection(result.activeAccountId);
527
+ } else {
528
+ const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel");
529
+ seedPoolRotationAccount(genericPoolKey(provider), result.activeAccountId);
530
+ }
518
531
  const { clearModelCache } = await import("../../codex/model-cache");
519
532
  const { clearGatherRoutedModelsInflight } = await import("../../codex/catalog");
520
533
  clearModelCache(provider);
@@ -323,6 +323,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
323
323
  { method: "PUT", path: "/api/oauth/accounts/active", module: "server/management/oauth-account-routes", mutates: true },
324
324
  { method: "PUT", path: "/api/oauth/accounts/alias", module: "server/management/oauth-account-routes", mutates: true },
325
325
  { method: "PUT", path: "/api/oauth/accounts/pause", module: "server/management/oauth-account-routes", mutates: true },
326
+ { method: "PUT", path: "/api/oauth/accounts/auto-switch", module: "server/management/oauth-account-routes", mutates: true },
326
327
  { method: "PUT", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
327
328
  { method: "PUT", path: "/api/providers/keys/active", module: "server/management/oauth-account-routes", mutates: true },
328
329
  { method: "PUT", path: "/api/providers/keys/alias", module: "server/management/oauth-account-routes", mutates: true },
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Credential. The account is the one the existing OAuth selection chooses for this request, by
6
6
  * the same steps the Responses pipeline's transport takes for an unpooled Anthropic OAuth route
7
- * (`prepareResponsesTransport`): capture the committed selection, resolve the active account's
8
- * access snapshot (refreshing it through the OAuth owner when it is due), and commit that
7
+ * (`prepareResponsesTransport`): capture the committed selection, resolve an eligible account's
8
+ * access snapshot (including pause/cooldown recovery), and commit that
9
9
  * proposal against the captured selection so a concurrent manual switch wins. Nothing here picks
10
10
  * an account of its own, and nothing runs at planning time: the planner and eligibility read
11
11
  * config only, and this module is reached from the lane's dispatch alone.
@@ -13,7 +13,8 @@
13
13
  * Pools. A pooled account set (the opt-in Anthropic account pool, or two or more usable accounts,
14
14
  * which turns on reactive 429 rotation) is declined before this lane is chosen
15
15
  * (`oauth-account-pool`). Should one appear between that decision and dispatch, resolution fails
16
- * closed rather than serve a pooled account without the pool's rotation and affinity.
16
+ * closed rather than serve a pooled account without the pool's rotation and affinity. This
17
+ * lane-change 409 is distinct from the shared local authentication/pause/cooldown refusals.
17
18
  *
18
19
  * Tool names. An OAuth request carries client tool names under the Claude OAuth prefix, as the
19
20
  * adapter sends them; the answer's `tool_use` names are mapped back here, for exactly the names
@@ -21,10 +22,12 @@
21
22
  *
22
23
  * No token, account id or body content is logged or returned in an error message.
23
24
  */
24
- import { getValidAccessTokenSnapshot, type OAuthAccessSnapshot } from "../oauth";
25
+ import type { OAuthAccessSnapshot } from "../oauth";
25
26
  import {
26
27
  commitAnthropicSelectionRouting,
27
28
  getAnthropicPoolAccessSnapshot,
29
+ getAnthropicAccountHealthSnapshot,
30
+ resolveAnthropicDispatchAccountId,
28
31
  hasAnthropicFailoverQuorum,
29
32
  isAnthropicAccountPoolEnabled,
30
33
  } from "../oauth/anthropic-routing";
@@ -72,17 +75,14 @@ function pooled(config: OcxConfig): boolean {
72
75
  * selection could not be committed.
73
76
  */
74
77
  export async function resolveNativeOAuthBinding(config: OcxConfig): Promise<NativeOAuthBinding> {
75
- if (pooled(config)) throw new NativeOAuthSelectionChangedError();
76
- let selection: Selection | null = captureOAuthAccountSelection(PROVIDER);
77
- let candidate = await getValidAccessTokenSnapshot(PROVIDER);
78
78
  for (let attempt = 0; attempt < MAX_SELECTION_ATTEMPTS; attempt++) {
79
- if (!selection) break;
80
- if (candidate.accountId !== selection.accountId) {
81
- // The active account moved between capture and resolution: serve the committed one.
82
- selection = captureOAuthAccountSelection(PROVIDER);
83
- if (!selection) break;
84
- candidate = await getAnthropicPoolAccessSnapshot(selection.accountId);
85
- }
79
+ const selection = captureOAuthAccountSelection(PROVIDER);
80
+ // Resolve eligibility before the lane guard: a local 401/403/429 must not turn
81
+ // into a generic lane-change 409 merely because it appeared during pacing.
82
+ const accountId = await resolveAnthropicDispatchAccountId(config);
83
+ if (pooled(config)) throw new NativeOAuthSelectionChangedError();
84
+ if (!selection) continue;
85
+ const candidate = await getAnthropicPoolAccessSnapshot(accountId);
86
86
  const committed = await commitOAuthAccountSelection(PROVIDER, candidate.accountId, {
87
87
  expectedSelection: selection,
88
88
  expectedCredentialGeneration: candidate.generation,
@@ -93,14 +93,12 @@ export async function resolveNativeOAuthBinding(config: OcxConfig): Promise<Nati
93
93
  config,
94
94
  sessionKey: null,
95
95
  expectedCredentialGeneration: candidate.generation,
96
- })) break;
96
+ })) continue;
97
97
  if (pooled(config)) break;
98
- return { selection: committed, snapshot: candidate };
98
+ const binding = { selection: committed, snapshot: candidate };
99
+ if (nativeOAuthBindingIsCurrent(binding)) return binding;
99
100
  }
100
- // A newer manual choice wins over this request's proposal.
101
- selection = captureOAuthAccountSelection(PROVIDER);
102
- if (!selection) break;
103
- candidate = await getAnthropicPoolAccessSnapshot(selection.accountId);
101
+ // Re-read after a lost commit or a health change during credential resolution.
104
102
  }
105
103
  throw new NativeOAuthSelectionChangedError();
106
104
  }
@@ -114,7 +112,8 @@ export function nativeOAuthBindingIsCurrent(binding: NativeOAuthBinding): boolea
114
112
  const row = getAccountCredentialWithStatus(PROVIDER, binding.snapshot.accountId);
115
113
  return selected?.accountId === binding.selection.accountId
116
114
  && selected?.revision === binding.selection.revision
117
- && !!row && !row.needsReauth && row.credential.expires > Date.now()
115
+ && !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now()
116
+ && !getAnthropicAccountHealthSnapshot(binding.snapshot.accountId)
118
117
  && credentialGeneration(row.credential) === binding.snapshot.generation;
119
118
  }
120
119
 
@@ -58,9 +58,9 @@ import {
58
58
  selectProactiveApiKeyTransport,
59
59
  transientRetryPolicyFor,
60
60
  } from "../providers/key-failover";
61
- import { stampApiKeyAccountLabel, stampOAuthAccountLabel } from "../providers/label";
62
- import { publicOAuthAuthenticationErrorMessage } from "../oauth";
63
- import { hasAnthropicFailoverQuorum } from "../oauth/anthropic-routing";
61
+ import { stampApiKeyAccountLabel } from "../providers/label";
62
+ import { OAuthAccountPausedError, OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../oauth";
63
+ import { AnthropicAccountCooldownError, formatAnthropicProviderForLog, hasAnthropicFailoverQuorum } from "../oauth/anthropic-routing";
64
64
  import { resolveProtocolSettings } from "../protocols/settings";
65
65
  import { addProtocolEntryReason, markProtocolBlocked } from "../protocols/trace";
66
66
  import type { OcxProviderTransport } from "../providers/xai-transport";
@@ -150,7 +150,7 @@ export interface HandleNativeMessagesOptions {
150
150
  callerAnthropicBeta?: string | null;
151
151
  }
152
152
 
153
- type FinishLog = (status: number, message?: string, closeReason?: FinalRequestLogMeta["closeReason"]) => void;
153
+ type FinishLog = (status: number, message?: string, meta?: FinalRequestLogMeta) => void;
154
154
 
155
155
  /** Relay an upstream body unchanged, recording first output on its first non-empty chunk. */
156
156
  function observeFirstChunk(body: ReadableStream<Uint8Array>, onFirst: () => void): ReadableStream<Uint8Array> {
@@ -311,10 +311,10 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
311
311
  attemptHandle.seal(logCtx.accountLogLabel);
312
312
  const { attempt } = attemptHandle;
313
313
  const finalLog = createFinalRequestLog(logIds, logCtx);
314
- const finishLog: FinishLog = (status, message, closeReason = "non_stream") => {
314
+ const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => {
315
315
  if (finalLog.finished()) return;
316
316
  if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500);
317
- finalLog.finish(status, { closeReason });
317
+ finalLog.finish(status, meta);
318
318
  };
319
319
  const bindUsage = (usage: OcxUsage | undefined) => {
320
320
  if (!usage) return;
@@ -328,6 +328,16 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
328
328
  finishLog(status, safeMessage);
329
329
  return anthropicErrorResponse(status, safeMessage, type, code);
330
330
  };
331
+ const localOAuthFailure = (error: unknown): Response | null => {
332
+ if (error instanceof AnthropicAccountCooldownError) {
333
+ const response = fail(429, error.message, "rate_limit_error");
334
+ if (error.retryAfterSeconds !== null) response.headers.set("retry-after", String(error.retryAfterSeconds));
335
+ return response;
336
+ }
337
+ if (error instanceof OAuthAccountPausedError) return fail(403, publicOAuthAuthenticationErrorMessage(error), "permission_error");
338
+ if (error instanceof OAuthLoginRequiredError) return fail(401, publicOAuthAuthenticationErrorMessage(error), "authentication_error");
339
+ return null;
340
+ };
331
341
 
332
342
  try {
333
343
  await prepareNativeBody(body, req.signal);
@@ -366,6 +376,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
366
376
  cleanupAbort();
367
377
  upstream.abort();
368
378
  if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error");
379
+ const localRefusal = localOAuthFailure(error);
380
+ if (localRefusal) return localRefusal;
369
381
  if (error instanceof NativeOAuthSelectionChangedError) return fail(409, error.message, "api_error");
370
382
  return fail(401, publicOAuthAuthenticationErrorMessage(error), "authentication_error");
371
383
  }
@@ -376,7 +388,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
376
388
  }
377
389
  let activeProvider: OcxProviderConfig = oauthBinding ? oauthProvider(oauthBinding) : route.provider;
378
390
  stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
379
- if (oauthBinding) stampOAuthAccountLabel(logCtx, route.providerName, activeProvider, oauthBinding.snapshot.accountId);
391
+ if (oauthBinding) logCtx.provider = formatAnthropicProviderForLog(route.providerName, oauthBinding.snapshot.accountId, config);
380
392
  const spendTracker = attachRequestSpendTracker(req, logCtx);
381
393
  let activeRequest: AnthropicMessagesPassthroughRequest;
382
394
  let retainedRequestBytes = 0;
@@ -460,14 +472,17 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
460
472
  if (oauthBinding) {
461
473
  // The OAuth twin of the key check below: re-resolve through the same selection
462
474
  // owner when the committed account or its credential moved since the build.
463
- if (!nativeOAuthBindingIsCurrent(oauthBinding)) {
475
+ for (let attempt = 0; !nativeOAuthBindingIsCurrent(oauthBinding); attempt++) {
476
+ if (attempt >= 3) throw new NativeOAuthSelectionChangedError();
464
477
  try {
465
478
  oauthBinding = await resolveNativeOAuthBinding(config);
466
- } catch {
479
+ } catch (error) {
480
+ if (error instanceof OAuthAccountPausedError || error instanceof OAuthLoginRequiredError
481
+ || error instanceof AnthropicAccountCooldownError) throw error;
467
482
  throw new NativeOAuthSelectionChangedError();
468
483
  }
469
484
  rebuildFor(oauthProvider(oauthBinding));
470
- stampOAuthAccountLabel(logCtx, route.providerName, activeProvider, oauthBinding.snapshot.accountId);
485
+ logCtx.provider = formatAnthropicProviderForLog(route.providerName, oauthBinding.snapshot.accountId, config);
471
486
  }
472
487
  } else if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) {
473
488
  const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider);
@@ -569,6 +584,8 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
569
584
  finishLog(429);
570
585
  return refusal;
571
586
  }
587
+ const localRefusal = localOAuthFailure(sendError);
588
+ if (localRefusal) return localRefusal;
572
589
  if (sendError instanceof NativeOAuthSelectionChangedError) return fail(409, sendError.message, "api_error");
573
590
  if (sendError instanceof NativeOpaqueStateRefusal) {
574
591
  logCtx.errorCode = "unsupported_feature";
@@ -597,7 +614,9 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
597
614
 
598
615
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
599
616
  if (contentType.includes("text/event-stream") && response.body) {
600
- const bodyGuard = resolvePassthroughBodyGuard(config, req.signal);
617
+ // `upstream` follows req.signal and is also what shutdown and turn release abort, so both
618
+ // the client leaving and this lane's own abort read as a cancel, not as a failed stream.
619
+ const bodyGuard = resolvePassthroughBodyGuard(config, upstream.signal);
601
620
  const observed = logIds ? observeFirstChunk(response.body, () => recordFirstOutput(logCtx, logIds.start)) : response.body;
602
621
  const renamed = activeRequest.oauthToolNames
603
622
  ? restoreOAuthToolNamesInSse(observed, activeRequest.oauthToolNames, translatorBudget)
@@ -609,7 +628,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
609
628
  try {
610
629
  cleanupAbort();
611
630
  bindUsage(logCtx.usage);
612
- finishLog(status, undefined, meta.closeReason);
631
+ finishLog(status, undefined, meta);
613
632
  if (meta.closeReason !== "terminal") upstream.abort();
614
633
  } finally {
615
634
  releaseStreamTurn();
@@ -621,9 +640,10 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
621
640
  });
622
641
  }
623
642
  // A non-streaming caller whose upstream streamed anyway: fold the stream into one message.
624
- const tapState: { closeReason?: FinalRequestLogMeta["closeReason"] } = {};
643
+ const tapState: { closeReason?: FinalRequestLogMeta["closeReason"]; meta?: FinalRequestLogMeta } = {};
625
644
  const tapped = tapAnthropicSseForLog(source, logCtx, (_status, meta) => {
626
645
  tapState.closeReason = meta.closeReason;
646
+ tapState.meta = meta;
627
647
  }, bodyGuard);
628
648
  try {
629
649
  const message = await collectAnthropicMessage(tapped, requestedModel, translatorBudget);
@@ -634,7 +654,12 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
634
654
  bindUsage(logCtx.usage);
635
655
  if (message.type === "error") {
636
656
  const error = isRec(message.error) ? message.error : {};
637
- return fail(502, typeof error.message === "string" ? error.message : "upstream stream failed", "api_error");
657
+ const text = typeof error.message === "string" ? error.message : "upstream stream failed";
658
+ // The tap's own verdict (a reset, a stall, the byte cap) closes this row with the same meta
659
+ // as the streaming lane's row; a plain upstream error event keeps the non_stream row.
660
+ const tapMeta = tapState.meta;
661
+ if (tapMeta && (tapMeta.terminalStatus || tapMeta.closeReason !== "terminal")) finishLog(502, text, tapMeta);
662
+ return fail(502, text, "api_error");
638
663
  }
639
664
  finishLog(200);
640
665
  return Response.json(message);
@@ -88,6 +88,8 @@ export type EagerRelayOptions = {
88
88
  upstreamError?: string;
89
89
  /** Optional client-facing hint policy; inspection retains original frames. */
90
90
  terminalBoundary?: CodexSafetyBufferingFilterOptions;
91
+ /** Mask the selected outbound credential in synthetic failures. */
92
+ maskCredential?: (text: string) => string;
91
93
  /** Injectable clock for tests. */
92
94
  now?: () => number;
93
95
  };
@@ -123,7 +125,7 @@ export function relaySseEagerBounded(
123
125
  ?? (hooks.rewritePayload ? payloadRewriteAsBlockRewrite(hooks.rewritePayload) : undefined);
124
126
  const encodeFailedTail = (error: unknown): Uint8Array | null => {
125
127
  try {
126
- return failedTailFrame(terminalEncoder, error);
128
+ return failedTailFrame(terminalEncoder, error, opts?.maskCredential);
127
129
  } catch {
128
130
  return null;
129
131
  }
@@ -140,7 +142,7 @@ export function relaySseEagerBounded(
140
142
  return encodeFailedTail(error);
141
143
  }
142
144
  try {
143
- return refusalFailedTailFrame(terminalEncoder, refusalMessage, refusalCode);
145
+ return refusalFailedTailFrame(terminalEncoder, refusalMessage, refusalCode, opts?.maskCredential);
144
146
  } catch {
145
147
  return null;
146
148
  }
@@ -335,6 +337,7 @@ export function relaySseEagerBounded(
335
337
  terminalEncoder,
336
338
  upstreamError,
337
339
  terminalBoundary.upstreamRefusalCode(),
340
+ opts?.maskCredential,
338
341
  );
339
342
  queuedBytes += upstreamErrorFrame.byteLength + terminalSentinel.byteLength;
340
343
  try {