@bitkyc08/opencodex 2.89.0 → 2.90.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 (129) hide show
  1. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  2. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
  3. package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
  4. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +2 -2
  7. package/src/adapters/base.ts +4 -0
  8. package/src/adapters/claude-cli/adapter.ts +13 -7
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  10. package/src/adapters/coding-agent/turn.ts +14 -4
  11. package/src/adapters/cursor/protobuf-request.ts +3 -0
  12. package/src/adapters/google-progress-history.ts +19 -1
  13. package/src/adapters/google.ts +28 -9
  14. package/src/adapters/image.ts +5 -3
  15. package/src/adapters/input-media-guard.ts +5 -2
  16. package/src/adapters/kiro-refusal.ts +41 -1
  17. package/src/adapters/ollama-native.ts +1 -0
  18. package/src/adapters/openai-chat/messages.ts +34 -4
  19. package/src/adapters/openai-chat/passthrough.ts +37 -4
  20. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  21. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  22. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  23. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  24. package/src/adapters/openai-chat.ts +7 -6
  25. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  26. package/src/adapters/openai-responses/passthrough.ts +5 -1
  27. package/src/adapters/openai-responses/transport.ts +13 -1
  28. package/src/claude/desktop-3p-library.ts +43 -3
  29. package/src/claude/desktop-3p.ts +20 -10
  30. package/src/claude/desktop-picker-profile.ts +18 -9
  31. package/src/claude/desktop-remote-store-io.ts +2 -2
  32. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  33. package/src/codex/account-lifecycle.ts +19 -6
  34. package/src/codex/account-store.ts +27 -8
  35. package/src/codex/account-usability.ts +4 -1
  36. package/src/codex/auth-api/account-list.ts +10 -5
  37. package/src/codex/auth-api/account-pause-group.ts +2 -22
  38. package/src/codex/auth-api/login-collision.ts +59 -0
  39. package/src/codex/auth-api/login-flow.ts +12 -7
  40. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  41. package/src/codex/auth-collision.ts +31 -4
  42. package/src/codex/auth-context.ts +48 -6
  43. package/src/codex/cli-update-plan.ts +18 -3
  44. package/src/codex/collision-identity.ts +78 -0
  45. package/src/codex/main-account-hard-lock.ts +14 -0
  46. package/src/codex/routing/cooldown-math.ts +5 -0
  47. package/src/codex/routing/failure-window.ts +61 -9
  48. package/src/codex/routing.ts +4 -3
  49. package/src/codex/shim-diagnostics.ts +2 -1
  50. package/src/codex/shim-fingerprint.ts +11 -2
  51. package/src/codex/shim-templates.ts +16 -5
  52. package/src/codex/shim-windows-refresh.ts +224 -0
  53. package/src/codex/shim.ts +45 -21
  54. package/src/combos/failover.ts +8 -1
  55. package/src/config/provider-validation.ts +7 -2
  56. package/src/generated/compatibility-version.json +168 -108
  57. package/src/generated/model-metadata.ts +1 -1
  58. package/src/images/loop.ts +2 -2
  59. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  60. package/src/lib/provider-admission-wait.ts +14 -0
  61. package/src/lib/service-secrets.ts +37 -2
  62. package/src/lib/translator-budget.ts +1 -1
  63. package/src/lib/upstream-retry.ts +15 -0
  64. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  65. package/src/oauth/account-import/parser.ts +15 -2
  66. package/src/oauth/account-import/types.ts +7 -0
  67. package/src/oauth/chatgpt-shared.ts +10 -1
  68. package/src/oauth/dsh.ts +45 -2
  69. package/src/oauth/kiro.ts +44 -1
  70. package/src/oauth/login-flow-state.ts +11 -2
  71. package/src/providers/claude-cli-usage.ts +20 -5
  72. package/src/providers/initial-model-selection.ts +2 -2
  73. package/src/providers/key-failover.ts +10 -2
  74. package/src/providers/key-store.ts +8 -3
  75. package/src/providers/mistral-transport.ts +207 -0
  76. package/src/providers/openai-sidecar.ts +5 -0
  77. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  78. package/src/providers/quota/antigravity.ts +6 -2
  79. package/src/providers/quota/failure-codes.ts +18 -0
  80. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  81. package/src/providers/quota-routing-cache.ts +1 -1
  82. package/src/providers/quota.ts +5 -1
  83. package/src/providers/registry/entries-extended.ts +6 -1
  84. package/src/providers/routed-config.ts +1 -1
  85. package/src/providers/xai-transport.ts +2 -1
  86. package/src/responses/audio.ts +46 -0
  87. package/src/responses/input-media.ts +37 -15
  88. package/src/responses/parser-content.ts +22 -27
  89. package/src/responses/reasoning-retention.ts +218 -11
  90. package/src/responses/schema.ts +3 -4
  91. package/src/responses/web-search-replay.ts +11 -2
  92. package/src/server/chat-native.ts +8 -7
  93. package/src/server/index/serve-options.ts +8 -2
  94. package/src/server/index/websocket-handler.ts +10 -0
  95. package/src/server/lifecycle.ts +17 -1
  96. package/src/server/management/oauth-account-routes.ts +2 -1
  97. package/src/server/messages-native.ts +11 -0
  98. package/src/server/request-log.ts +16 -7
  99. package/src/server/responses/adapter-continuation.ts +2 -2
  100. package/src/server/responses/adapter-dispatch.ts +2 -2
  101. package/src/server/responses/codex-ws-exchange.ts +3 -0
  102. package/src/server/responses/codex-ws-metadata.ts +44 -0
  103. package/src/server/responses/codex-ws-session.ts +21 -1
  104. package/src/server/responses/combo-stream-preflight.ts +7 -2
  105. package/src/server/responses/compact.ts +2 -0
  106. package/src/server/responses/core-auth.ts +20 -0
  107. package/src/server/responses/core-codex-account.ts +21 -14
  108. package/src/server/responses/core-combo.ts +3 -0
  109. package/src/server/responses/core-opaque-recovery.ts +20 -11
  110. package/src/server/responses/deepseek-image-files.ts +121 -0
  111. package/src/server/responses/empty-completion-guard.ts +8 -0
  112. package/src/server/responses/fetch-helpers.ts +30 -10
  113. package/src/server/responses/input-admission.ts +6 -2
  114. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  115. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  116. package/src/server/responses/passthrough-completion.ts +41 -0
  117. package/src/server/responses/passthrough-delivery.ts +2 -0
  118. package/src/server/responses/passthrough-dispatch.ts +13 -13
  119. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  120. package/src/server/responses/reasoning-archive-error.ts +21 -0
  121. package/src/server/responses/request-prepare.ts +11 -1
  122. package/src/server/responses/request-transport.ts +61 -3
  123. package/src/service/orchestration.ts +17 -0
  124. package/src/types/provider.ts +2 -0
  125. package/src/types/request.ts +16 -1
  126. package/src/types.ts +1 -0
  127. package/src/web-search/loop.ts +2 -2
  128. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  129. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
package/src/oauth/kiro.ts CHANGED
@@ -205,11 +205,54 @@ function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefin
205
205
  : undefined);
206
206
  }
207
207
 
208
+ /**
209
+ * `kiro-cli whoami --format json` prints its identity as one JSON object, but once an IAM Identity
210
+ * Center profile is selected kiro-cli 2.32 follows that object with a human-readable `Profile:`
211
+ * block on stdout. Parsing the whole stream then failed, which silently dropped the user email that
212
+ * tells organization users apart (#6641). Only the leading top-level object is identity evidence;
213
+ * it must start the output and end its line, so text on later lines is ignored while a second
214
+ * value on the same line makes the identity unreadable.
215
+ */
216
+ function parseWhoamiObject(stdout: string): Record<string, unknown> | undefined {
217
+ const start = stdout.search(/\S/);
218
+ if (start < 0 || stdout[start] !== "{") return undefined;
219
+ let depth = 0;
220
+ let inString = false;
221
+ let escaped = false;
222
+ for (let index = start; index < stdout.length; index++) {
223
+ const char = stdout[index];
224
+ if (inString) {
225
+ if (escaped) escaped = false;
226
+ else if (char === "\\") escaped = true;
227
+ else if (char === "\"") inString = false;
228
+ continue;
229
+ }
230
+ if (char === "\"") inString = true;
231
+ else if (char === "{" || char === "[") depth++;
232
+ else if (char === "}" || char === "]") {
233
+ depth--;
234
+ if (depth > 0) continue;
235
+ if (!/^[ \t]*(?:\r?\n|$)/.test(stdout.slice(index + 1))) return undefined;
236
+ const parsed: unknown = JSON.parse(stdout.slice(start, index + 1));
237
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
238
+ ? parsed as Record<string, unknown>
239
+ : undefined;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * Same-session identity reported by `kiro-cli whoami --format json`: the lower-cased user email
247
+ * and a structurally valid profile ARN. Any failure (non-zero exit, unreadable output, a thrown
248
+ * runner) yields an empty identity, so a login falls back to the SQLite import instead of failing.
249
+ */
208
250
  async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string; profileArn?: string }> {
209
251
  try {
210
252
  const result = await runner(["whoami", "--format", "json"], signal);
211
253
  if (result.exitCode !== 0) return {};
212
- const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
254
+ const parsed = parseWhoamiObject(result.stdout);
255
+ if (!parsed) return {};
213
256
  const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
214
257
  const profileArn = profileArnFromWhoami(parsed);
215
258
  return {
@@ -297,14 +297,23 @@ export function clearLoginState(provider: string): void {
297
297
  /**
298
298
  * Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow
299
299
  * is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a
300
- * different provider is refused rather than cancelled.
300
+ * different provider is refused rather than cancelled. Only the public OAuth cancellation
301
+ * endpoint opts into failed-outcome dismissal; callers using this boolean as proof of an
302
+ * aborted controller (Codex flow ownership) retain their original contract.
301
303
  */
302
- export function cancelLoginFlow(provider: string, flowId?: string): boolean {
304
+ export function cancelLoginFlow(provider: string, flowId?: string, options?: { dismissSettledFailure?: boolean }): boolean {
303
305
  const flowKey = flowId ?? latestLoginFlowKey(provider);
304
306
  if (flowKey === undefined) return false;
305
307
  const active = loginAbort.get(flowKey);
306
308
  const existing = loginState.get(flowKey);
307
309
  if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false;
310
+ if (options?.dismissSettledFailure && !active && existing?.done && existing.error) {
311
+ // A failed add-account attempt does not invalidate a previously signed-in account. Let
312
+ // its owner dismiss the stale UI outcome without erasing credentials or another flow.
313
+ clearManualCodeSlot(flowKey);
314
+ dropLoginFlow(flowKey);
315
+ return true;
316
+ }
308
317
  if (!active && (!existing || existing.done)) return false;
309
318
  abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" });
310
319
  return true;
@@ -4,7 +4,8 @@ import { bindRawDecisionWindows } from "./quota-decision-publication";
4
4
  import { decisionQuotaSinkActive } from "../lib/decision-quota-sink";
5
5
  import { CLAUDE_CLI_USER_AGENT } from "./claude-cli-identity";
6
6
  import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TIMEOUT_MS } from "./quota-wire";
7
- import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
7
+ import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "./quota-types";
8
+ import { httpQuotaFailure } from "./quota/failure-codes";
8
9
  const hasQuotaRows = (q: ProviderQuota) => q.fiveHourPercent !== undefined || q.weeklyPercent !== undefined || Boolean(q.customWindows?.length);
9
10
 
10
11
  function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {
@@ -46,6 +47,16 @@ function parseClaudeLimit(value: unknown): ProviderQuotaWindow | null {
46
47
 
47
48
  /** Read the Anthropic OAuth usage endpoint for an access token and return the parsed quota, or null on a non-OK or malformed response. */
48
49
  export async function readAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
50
+ return (await probeAnthropicUsageQuota(accessToken)).quota;
51
+ }
52
+
53
+ /** A usage read plus, when it yields no quota, the closed reason why. A thrown fetch still throws. */
54
+ export interface AnthropicUsageProbe {
55
+ quota: ProviderQuota | null;
56
+ failure?: QuotaFailureCode;
57
+ }
58
+
59
+ export async function probeAnthropicUsageQuota(accessToken: string): Promise<AnthropicUsageProbe> {
49
60
  const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
50
61
  headers: {
51
62
  Accept: "application/json, text/plain, */*",
@@ -56,9 +67,12 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
56
67
  },
57
68
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
58
69
  });
59
- if (!response.ok) return null;
70
+ if (!response.ok) {
71
+ try { await response.body?.cancel(); } catch { /* Best-effort release; the status is the diagnosis. */ }
72
+ return { quota: null, failure: httpQuotaFailure(response.status) };
73
+ }
60
74
  const body = asRecord(await readQuotaJson(response));
61
- if (!body) return null;
75
+ if (!body) return { quota: null, failure: "response_unusable" };
62
76
  const fiveHour = parseClaudeBucket(body.five_hour);
63
77
  const sevenDay = parseClaudeBucket(body.seven_day);
64
78
  const fable = parseClaudeBucket(body.seven_day_fable);
@@ -93,9 +107,10 @@ export async function readAnthropicUsageQuota(accessToken: string): Promise<Prov
93
107
  };
94
108
  if (decisionQuotaSinkActive()) bindRawDecisionWindows(quota, rawAnthropicUsageWindows(body, quota.updatedAt));
95
109
  // Empty / schema-changed payloads must not cache as "success with no bars".
96
- return hasQuotaRows(quota) ? markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
110
+ if (!hasQuotaRows(quota)) return { quota: null, failure: "response_unusable" };
111
+ return { quota: markAnthropicFamilyEnumeration(quota, Array.isArray(body.limits) && body.limits.every(raw => {
97
112
  const kind = asRecord(raw)?.kind;
98
113
  return kind === "session" || kind === "weekly_all" || kind === "weekly_scoped" && parseClaudeLimit(raw) !== null;
99
- })) : null;
114
+ })) };
100
115
  }
101
116
 
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry";
4
4
  import { routedSlug, slugEquivalenceKey } from "./slug-codec";
5
5
  import { comboDisabledModelSelectors } from "../combos/types";
6
- import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store";
6
+ import { providerUsesKeyAuthOverride } from "./key-store";
7
7
 
8
8
  export const INITIAL_MODEL_SELECTION_THRESHOLD = 20;
9
9
  type Selection = NonNullable<OcxProviderConfig["initialModelSelection"]>;
@@ -31,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean {
31
31
  if (entry && providerMatchesRegistryTransport(name, provider)) {
32
32
  if (entry.authKind === "forward") return true;
33
33
  if (entry.authKind === "oauth") {
34
- return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey));
34
+ return !providerUsesKeyAuthOverride(entry, provider);
35
35
  }
36
36
  }
37
37
  return provider.authMode === "oauth" || provider.authMode === "forward";
@@ -12,6 +12,7 @@ import { isKeyInCooldown, getKeyCooldownUntil, setKeyCooldown } from "./api-key-
12
12
  export { clearKeyCooldowns, getKeyCooldownUntil, sweepExpiredApiKeyCooldowns } from "./api-key-cooldowns";
13
13
  import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection";
14
14
  import { RequestPacingQueueOverloadError } from "./request-pacing";
15
+ import { isCanonicalMistralChat } from "./mistral-transport";
15
16
  import type { ProviderApiKeySelection } from "../types/provider";
16
17
  import { routedProviderConfig } from "./routed-config";
17
18
  import { getProviderRegistryEntry } from "./registry";
@@ -585,7 +586,7 @@ function allocateDistributedKey(providerName: string, provider: OcxProviderConfi
585
586
  * (subscription traffic such as Muse Spark) falls back to a patient same-key policy so a
586
587
  * burst 429 waits and replays instead of surfacing to the client and aborting a long
587
588
  * session; key-auth Command Code at its canonical endpoints gets the same fallback (#5180), and
588
- * every other provider without the knob keeps today's fail-fast behavior.
589
+ * canonical Mistral Chat uses a slower fallback; other destinations stay fail-fast.
589
590
  * OAuth/forward/local credentials are never replayed on the same token. The returned
590
591
  * policy is fully defaulted so callers never re-check fields.
591
592
  */
@@ -609,8 +610,15 @@ export function rateLimitRetryPolicyFor(
609
610
  respectRetryAfter: policy.respectRetryAfter ?? DEFAULT_RATE_LIMIT_RETRY.respectRetryAfter,
610
611
  };
611
612
  }
612
- // No explicit knob: patient fallback for OpenCode Go and canonical Command Code only.
613
+ // No explicit knob: destination-specific fallbacks, never OAuth or local credentials.
613
614
  if (provider.authMode !== undefined && provider.authMode !== "key") return null;
615
+ // Mistral's token-throughput and temporary capacity refusals both use HTTP 429.
616
+ // Keep relays and other wire adapters opt-in, and reuse the existing send budget.
617
+ if (typeof provider.baseUrl === "string" && typeof provider.adapter === "string" && isCanonicalMistralChat({
618
+ adapter: provider.adapter, authMode: provider.authMode, baseUrl: provider.baseUrl,
619
+ })) {
620
+ return { ...DEFAULT_RATE_LIMIT_RETRY, attempts: 6, intervalMs: 10_000 };
621
+ }
614
622
  if (isOpenCodeGoDestination(provider)) return { ...OPENCODE_GO_RATE_LIMIT_RETRY };
615
623
  // Command Code's Provider API rate-limits long muse-spark turns the same way (#5180): a single
616
624
  // key cannot fail over, and the Codex client does not retry a 429, so the turn aborts. The key
@@ -32,14 +32,19 @@ export type {
32
32
  ProviderKeyStoreKind,
33
33
  } from "./api-key-resolve";
34
34
 
35
- /** Shared with routing: a key-mode override is effective only while its key resolves. */
35
+ /**
36
+ * Shared with routing: a key-mode override is operator intent, not a credential probe.
37
+ * The configured authMode alone decides the lane; a key that cannot be resolved right now
38
+ * (paused last key, missing env var, disabled pool) fails closed at the transport and must
39
+ * never implicitly reclassify the provider to the registry's OAuth lane, where a stored
40
+ * OAuth account would be billed instead of the operator's key.
41
+ */
36
42
  export function providerUsesKeyAuthOverride(
37
43
  entry: Pick<ProviderRegistryEntry, "authKind" | "allowKeyAuthOverride">,
38
44
  provider: Pick<OcxProviderConfig, "authMode">,
39
- resolvedKey: string | undefined,
40
45
  ): boolean {
41
46
  return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true
42
- && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0;
47
+ && provider.authMode === "key";
43
48
  }
44
49
 
45
50
  /**
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
3
+ import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress";
4
+ import { configuredOutboundFetch } from "../lib/proxy-env";
5
+ import { waitOutsideProviderHeaderDeadline } from "../lib/provider-admission-wait";
6
+ import { markSameTargetRetryHold, sleepWithAbort } from "../lib/upstream-retry";
7
+ import { RequestPacingQueueOverloadError } from "./request-pacing";
8
+ import type { OcxProviderTransport } from "./xai-transport";
9
+
10
+ const MAX_IDENTITIES = 128;
11
+ const DEFAULT_MAX_WAIT_MS = 60_000; // Existing retry wait budget, not an inferred rate window.
12
+ const MINUTE_MS = 60 * 1000; // Unit named by the observed headers, never an account quota.
13
+ type RecoveryState = { generation: number; probe?: { done: Promise<void>; finish(): void } };
14
+
15
+ /** Match the supported key-auth Chat destination, including one optional trailing slash. */
16
+ export function isCanonicalMistralChat(
17
+ provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "baseUrl">,
18
+ ): boolean {
19
+ if (provider.adapter !== "openai-chat" || (provider.authMode !== undefined && provider.authMode !== "key")) return false;
20
+ try {
21
+ const url = new URL(provider.baseUrl);
22
+ return url.origin === "https://api.mistral.ai" && /^\/v1\/?$/.test(url.pathname)
23
+ && !url.username && !url.password && !url.search && !url.hash;
24
+ } catch { return false; }
25
+ }
26
+
27
+ /** Read a nonnegative integer counter; reject missing or malformed values. */
28
+ function count(headers: Headers, name: string): number | undefined {
29
+ const raw = headers.get(name)?.trim();
30
+ if (!raw || !/^\d+$/.test(raw)) return undefined;
31
+ const value = Number(raw);
32
+ return Number.isSafeInteger(value) ? value : undefined;
33
+ }
34
+
35
+ /** Read the server delay as seconds or an HTTP date; ignore invalid hints. */
36
+ function retryDelay(headers: Headers, now: number): number | undefined {
37
+ const raw = headers.get("retry-after")?.trim();
38
+ if (!raw) return undefined;
39
+ const value = /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - now;
40
+ return Number.isFinite(value) ? Math.max(0, value) : undefined;
41
+ }
42
+
43
+ /** Classify measured refusal evidence only; never predict a future cache hit or token charge. */
44
+ export function mistralMinuteRefusal(headers: Headers): "temporary" | "charge-above-allowance" | "unknown" {
45
+ const month = count(headers, "x-ratelimit-limit-tokens-month");
46
+ if (month !== undefined && count(headers, "x-ratelimit-remaining-tokens-month") === 0) return "unknown";
47
+ const limit = count(headers, "x-ratelimit-limit-tokens-minute");
48
+ const remaining = count(headers, "x-ratelimit-remaining-tokens-minute");
49
+ const cost = count(headers, "x-ratelimit-tokens-query-cost");
50
+ if (limit !== undefined && limit > 0 && cost !== undefined && cost > limit) return "charge-above-allowance";
51
+ if (limit !== undefined && limit > 0 && remaining !== undefined && remaining <= limit
52
+ && (remaining === 0 || (cost !== undefined && cost > remaining))) return "temporary";
53
+ const requests = count(headers, "x-ratelimit-limit-req-minute");
54
+ if (requests !== undefined && requests > 0 && count(headers, "x-ratelimit-remaining-req-minute") === 0) return "temporary";
55
+ return "unknown";
56
+ }
57
+
58
+ /** Process-local observations, never a fixed account/plan quota or a persisted credential. */
59
+ export class MistralBudgetPacer {
60
+ private readonly deadlines = new Map<string, number>();
61
+ private readonly recoveries = new Map<string, RecoveryState>();
62
+ /** Latest live deadline evicted at capacity; unknown identities wait it out instead of sending early. */
63
+ private evictedUntil = 0;
64
+ private waiting = 0;
65
+
66
+ constructor(
67
+ private readonly now: () => number = Date.now,
68
+ private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void> = sleepWithAbort,
69
+ ) {}
70
+
71
+ /** Record server delays and confirmed minute pressure for this credential and model. */
72
+ observe(identity: string, headers: Headers, status = 200): void {
73
+ const tokens = count(headers, "x-ratelimit-remaining-tokens-minute");
74
+ const requests = count(headers, "x-ratelimit-remaining-req-minute");
75
+ // Healthy remaining counters never predict the next request's cache/charge.
76
+ if (status !== 429 && tokens !== 0 && requests !== 0) return;
77
+ // With no explicit time, activate a conservative minute hold only AFTER a
78
+ // refusal establishes pressure. Healthy responses never predict the next cost.
79
+ const delay = retryDelay(headers, this.now())
80
+ ?? (status === 429 && mistralMinuteRefusal(headers) === "temporary" ? MINUTE_MS : undefined);
81
+ if (delay === undefined || delay === 0) return;
82
+ for (const [key, deadline] of this.deadlines) {
83
+ if (deadline <= this.now()) this.deadlines.delete(key);
84
+ }
85
+ if (!this.deadlines.has(identity) && this.deadlines.size >= MAX_IDENTITIES) {
86
+ const [evicted, until] = this.deadlines.entries().next().value!;
87
+ this.evictedUntil = Math.max(this.evictedUntil, until);
88
+ this.deadlines.delete(evicted);
89
+ }
90
+ this.deadlines.set(identity, Math.max(this.deadlines.get(identity) ?? 0, this.now() + delay));
91
+ if (status === 429) {
92
+ if (!this.recoveries.has(identity) && this.recoveries.size >= MAX_IDENTITIES) {
93
+ const idle = [...this.recoveries].find(([, state]) => !state.probe);
94
+ if (!idle) return;
95
+ this.recoveries.delete(idle[0]);
96
+ }
97
+ const state = this.recoveries.get(identity) ?? { generation: 0 };
98
+ state.generation++;
99
+ this.recoveries.set(identity, state);
100
+ }
101
+ }
102
+
103
+ /** Wait within the admission budget and return a release callback for one recovery probe. */
104
+ async acquire(identity: string, signal?: AbortSignal, maxWaitMs = DEFAULT_MAX_WAIT_MS): Promise<(success?: boolean) => void> {
105
+ signal?.throwIfAborted();
106
+ if (this.waiting >= MAX_IDENTITIES) throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
107
+ this.waiting++;
108
+ const deadline = this.now() + maxWaitMs;
109
+ try {
110
+ for (;;) {
111
+ signal?.throwIfAborted();
112
+ // An evicted identity may have re-entered with a shorter delay; the evicted one still binds.
113
+ const live = this.evictedUntil > this.now() ? this.evictedUntil : undefined;
114
+ const own = this.deadlines.get(identity);
115
+ const readyAt = own === undefined ? live : live === undefined ? own : Math.max(own, live);
116
+ if (readyAt !== undefined && readyAt > deadline) {
117
+ const delay = readyAt - this.now();
118
+ throw new RequestPacingQueueOverloadError("mistral", "queue_expired", Math.ceil(delay / 1000));
119
+ }
120
+ if (readyAt !== undefined && readyAt > this.now()) {
121
+ await this.sleep(readyAt - this.now(), signal);
122
+ continue;
123
+ }
124
+ const state = this.recoveries.get(identity);
125
+ if (!state) { this.deadlines.delete(identity); return () => {}; }
126
+ if (state.probe) {
127
+ const remaining = deadline - this.now();
128
+ if (remaining <= 0) throw new RequestPacingQueueOverloadError("mistral", "queue_expired", 1);
129
+ const local = new AbortController();
130
+ try {
131
+ await Promise.race([state.probe.done, this.sleep(remaining, AbortSignal.any([local.signal, ...(signal ? [signal] : [])]))]);
132
+ } finally { local.abort(); }
133
+ continue;
134
+ }
135
+ let finish!: () => void;
136
+ const probe = { done: new Promise<void>(resolve => { finish = resolve; }), finish: () => finish() };
137
+ state.probe = probe;
138
+ const generation = state.generation;
139
+ let released = false;
140
+ return success => {
141
+ if (released) return;
142
+ released = true;
143
+ if (state.probe === probe) delete state.probe;
144
+ // Only this probe can settle recovery; an older concurrent success must
145
+ // not erase a more recent refusal or the server's minimum wait.
146
+ if (success && state.generation === generation) {
147
+ if (this.recoveries.get(identity) === state) this.recoveries.delete(identity);
148
+ if ((this.deadlines.get(identity) ?? 0) <= this.now()) this.deadlines.delete(identity);
149
+ }
150
+ probe.finish();
151
+ };
152
+ }
153
+ } finally { this.waiting--; }
154
+ }
155
+ }
156
+
157
+ const pacer = new MistralBudgetPacer();
158
+ const wrapped = new WeakSet<typeof globalThis.fetch>();
159
+
160
+ /** Wrap canonical Mistral Chat sends with bounded admission and server-delay protection. */
161
+ export function resolveMistralTransport(
162
+ provider: OcxProviderTransport,
163
+ budgetPacer = pacer,
164
+ ): OcxProviderTransport {
165
+ if (!isCanonicalMistralChat(provider) || (provider.fetch && wrapped.has(provider.fetch))) return provider;
166
+ const baseFetch = provider.fetch
167
+ ?? markEgressTransparentExecutor(((input, init) => configuredOutboundFetch(input, init)) as typeof globalThis.fetch);
168
+ const attemptFetch = (async (input, init) => {
169
+ const url = new URL(input instanceof Request ? input.url : String(input));
170
+ if (url.origin !== "https://api.mistral.ai" || url.pathname !== "/v1/chat/completions"
171
+ || url.search || url.hash || init?.method?.toUpperCase() !== "POST" || typeof init.body !== "string") {
172
+ return baseFetch(input, init);
173
+ }
174
+ // The Chat adapter serializes model first. Read only that bounded prefix rather
175
+ // than parsing/copying a potentially million-token conversation a second time.
176
+ const encodedModel = /^\s*\{\s*"model"\s*:\s*("(?:[^"\\]|\\.)*")\s*[,}]/.exec(init.body.slice(0, 4096))?.[1];
177
+ let model: unknown;
178
+ try { model = encodedModel === undefined ? undefined : JSON.parse(encodedModel); } catch { return baseFetch(input, init); }
179
+ const authorization = new Headers(init.headers).get("authorization");
180
+ if (!authorization || typeof model !== "string" || !model || model.length > 256) return baseFetch(input, init);
181
+ // Bind to the actual send credential, including after pool rotation; keep no raw key.
182
+ const identity = createHash("sha256").update(JSON.stringify([authorization, model])).digest("hex");
183
+ const pacing = provider.requestPacing?.enabled !== false && provider.retryOn429?.respectRetryAfter !== false;
184
+ const release = pacing ? await waitOutsideProviderHeaderDeadline(init,
185
+ signal => budgetPacer.acquire(identity, signal, provider.retryOn429?.maxIntervalMs)) : undefined;
186
+ let response: Response;
187
+ try {
188
+ init.signal?.throwIfAborted();
189
+ response = await baseFetch(input, init);
190
+ if (pacing) budgetPacer.observe(identity, response.headers, response.status);
191
+ } catch (error) { release?.(); throw error; }
192
+ release?.(response.ok);
193
+ const retry = provider.retryOn429;
194
+ if (response.status === 429 && retry?.respectRetryAfter !== false) {
195
+ const delay = retryDelay(response.headers, Date.now());
196
+ if (delay !== undefined && delay > (retry?.maxIntervalMs ?? DEFAULT_MAX_WAIT_MS)) {
197
+ // The shared retry helper caps waits. Stop same-key replays sooner than Mistral permits;
198
+ // another credential may still serve, and the original status/body/Retry-After reach the caller.
199
+ markSameTargetRetryHold(response);
200
+ }
201
+ }
202
+ return response;
203
+ }) as typeof globalThis.fetch;
204
+ if (isEgressTransparentExecutor(baseFetch)) markEgressTransparentExecutor(attemptFetch);
205
+ wrapped.add(attemptFetch);
206
+ return { ...provider, fetch: attemptFetch };
207
+ }
@@ -12,6 +12,7 @@ import {
12
12
  type CodexAuthPolicyConfig,
13
13
  } from "../codex/auth-context";
14
14
  import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing";
15
+ import { codexFailureWindowOutcomeMeta } from "../codex/routing/failure-window";
15
16
  import { inspectChatGptDomainClaim } from "../oauth/chatgpt";
16
17
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors";
17
18
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
@@ -205,6 +206,7 @@ export async function resolveFirstUsableOpenAiSidecar(
205
206
  releaseCodexAuthContextProbeLease(authContext);
206
207
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
207
208
  }
209
+ const windowMeta = codexFailureWindowOutcomeMeta(authContext);
208
210
  return {
209
211
  ...candidate,
210
212
  authContext,
@@ -220,6 +222,7 @@ export async function resolveFirstUsableOpenAiSidecar(
220
222
  probeLeaseId: authContext.probeLeaseId,
221
223
  probeQuotaScope: authContext.probeQuotaScope,
222
224
  writerGeneration: authContext.writerGeneration,
225
+ ...windowMeta,
223
226
  // 401/403 here is evidence about this exact stored credential; without the generation a
224
227
  // replacement inherits the quarantine (#2892 gap 4).
225
228
  ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
@@ -256,6 +259,7 @@ export async function resolveFirstUsableOpenAiSidecar(
256
259
  releaseCodexAuthContextProbeLease(authContext);
257
260
  continue;
258
261
  }
262
+ const windowMeta = codexFailureWindowOutcomeMeta(authContext);
259
263
  return {
260
264
  ...candidate,
261
265
  authContext,
@@ -271,6 +275,7 @@ export async function resolveFirstUsableOpenAiSidecar(
271
275
  threadId: authContext.affinityKey,
272
276
  probeLeaseId: authContext.probeLeaseId,
273
277
  writerGeneration: authContext.writerGeneration,
278
+ ...windowMeta,
274
279
  // Same fence as the exact-account recorder above (#2892 gap 4).
275
280
  ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
276
281
  },
@@ -4,7 +4,9 @@ import { getAccountSet } from "../../oauth/store";
4
4
  import { captureConfigGeneration, sweepExpiredOnWrite } from "../../lib/state-store-sweeper";
5
5
  import { configuredAnthropicInstance, type AnthropicInstanceId } from "../anthropic-instance";
6
6
  import { ACCOUNT_QUOTA_TTL_MS } from "../quota-wire";
7
- import { fetchAnthropicUsageQuotaForInstance } from "./vendor-probes-oauth";
7
+ import { fetchAnthropicUsageProbeForInstance } from "./vendor-probes-oauth";
8
+ import { thrownQuotaFailure } from "./failure-codes";
9
+ import type { QuotaFailureCode } from "../quota-types";
8
10
  import { AnthropicQuotaProbeOwnershipError, anthropicCooldownRecoveryFor, type AnthropicQuotaRecoveryResult } from "./anthropic-cooldown-recovery";
9
11
  import {
10
12
  accountCacheKey, accountQuotaCache, accountQuotaInflight, captureProviderAccountQuotaEpoch,
@@ -49,7 +51,10 @@ export async function fetchAnthropicAccountQuota(
49
51
  }
50
52
  let token: string;
51
53
  try { token = await getTokenForAccountQuotaProbe(instance, accountId); }
52
- catch { return unavailable(); }
54
+ catch {
55
+ // Missing, signed-out or unrefreshable credential: a reauthentication matter, never a quota fact.
56
+ return { ...unavailable(), quotaFailure: "account_unavailable", quotaFailureIsCurrent: ownerCurrent };
57
+ }
53
58
  if (!ownerCurrent()) return unavailable();
54
59
  const flightKey = recovery.anthropicCredentialQuotaFlightKey(key, accountId);
55
60
  const flightCurrent = () => ownerCurrent()
@@ -65,30 +70,37 @@ export async function fetchAnthropicAccountQuota(
65
70
  // in-flight observations, as the pre-instance A path did, so joined callers and later cached
66
71
  // reads agree and windows that expired during the shared probe are normalized away.
67
72
  /** Cache the settled probe result for an account and publish its decision evidence only while the flight is still current for its credential and generation. */
68
- const publish = (result: AnthropicQuotaRecoveryResult | null): AccountQuotaCacheEntry => {
73
+ const publish = (result: AnthropicQuotaRecoveryResult | null, failure?: QuotaFailureCode): AccountQuotaCacheEntry => {
69
74
  const previous = accountQuotaCache.get(key);
70
75
  const retained = previous?.isCurrent?.() === false ? undefined : previous;
71
76
  const isCurrent = result?.isCurrent ?? flightCurrent;
72
77
  const entry: AccountQuotaCacheEntry = {
73
78
  ts: Date.now(), quota: normalizeAnthropicQuota(result?.quota ?? retained?.quota, Date.now()),
74
79
  ...(!result ? { unavailable: true as const } : {}), isCurrent,
80
+ ...(!result && failure ? { quotaFailure: failure, quotaFailureIsCurrent: isCurrent } : {}),
75
81
  };
76
82
  if (isCurrent() && mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); result?.publishDecisionQuota(); sweepExpiredOnWrite(entry.ts); }
77
83
  return entry;
78
84
  };
79
85
  const probe = (async (): Promise<AccountQuotaCacheEntry> => {
86
+ // The closed reason of the last usage read in this flight: an HTTP status class, an unusable
87
+ // body, or a thrown dispatch. Ownership and cooldown refusals never set it.
88
+ let failure: QuotaFailureCode | undefined;
80
89
  try {
81
90
  const result = await recovery.probeAnthropicQuotaWithRecovery(accountId, token, fresh => {
82
91
  if (!flightCurrent()) throw new AnthropicQuotaProbeOwnershipError("quota flight changed");
83
92
  recovery.assertAnthropicQuotaSendAllowed(accountId, token);
84
- return fetchAnthropicUsageQuotaForInstance(instance, token, fresh || freshDispatch);
93
+ return fetchAnthropicUsageProbeForInstance(instance, token, fresh || freshDispatch).then(
94
+ read => { failure = read.failure; return read.quota; },
95
+ error => { failure = thrownQuotaFailure(error); throw error; },
96
+ );
85
97
  }, () => ownerCurrent() && mayCommitAccountQuotaKey(key, writerGeneration));
86
98
  if (result && !result.isCurrent()) return unavailable();
87
99
  if (!result && !flightCurrent()) return unavailable();
88
- return publish(result);
100
+ return publish(result, failure);
89
101
  } catch (error) {
90
102
  if (error instanceof AnthropicQuotaProbeOwnershipError || !flightCurrent()) return unavailable();
91
- return publish(null);
103
+ return publish(null, failure);
92
104
  }
93
105
  })().finally(() => { if (accountQuotaInflight.get(flightKey) === probe) accountQuotaInflight.delete(flightKey); });
94
106
  accountQuotaInflight.set(flightKey, probe);
@@ -306,13 +306,16 @@ function antigravityUnavailableFailure(
306
306
  return fallbackFailure;
307
307
  }
308
308
 
309
- export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise<AntigravityQuotaProbeResult> {
309
+ export async function probeAntigravityUsageQuota(accessToken: string, projectId: string, signal?: AbortSignal): Promise<AntigravityQuotaProbeResult> {
310
+ // An optional caller signal (account import) cancels every request of this probe, including the
311
+ // 403 retry and the models fallback, alongside the per-request deadline.
312
+ const requestSignal = () => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
310
313
  const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
311
314
  headers: {
312
315
  Accept: "application/json", "Content-Type": "application/json",
313
316
  "User-Agent": userAgent, Authorization: `Bearer ${accessToken}`,
314
317
  },
315
- body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
318
+ body: JSON.stringify({ project: projectId }), signal: requestSignal(),
316
319
  }, antigravityOutboundDependencies);
317
320
  let summaryFailure: QuotaFailureCode | undefined;
318
321
  try {
@@ -332,6 +335,7 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
332
335
  // Existing behavior: summary transport/parse failure may recover through the models probe.
333
336
  summaryFailure = quotaTransportFailure(error);
334
337
  }
338
+ if (signal?.aborted) return unavailableAntigravityQuota(summaryFailure ?? "transport_error");
335
339
  try {
336
340
  const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL);
337
341
  if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) {
@@ -0,0 +1,18 @@
1
+ import type { QuotaFailureCode } from "../quota-types";
2
+
3
+ /**
4
+ * Closed quota-probe diagnoses derived from an HTTP status class or a thrown dispatch. They carry
5
+ * no upstream text, URL, header or credential: only one of `QUOTA_FAILURE_CODES`.
6
+ */
7
+ export function httpQuotaFailure(status: number): QuotaFailureCode {
8
+ if (status >= 300 && status < 400) return "redirect_blocked";
9
+ if (status === 401 || status === 403) return "access_denied";
10
+ if (status === 429) return "rate_limited";
11
+ return "upstream_error";
12
+ }
13
+
14
+ /** A thrown fetch: the request deadline is a timeout, anything else a transport failure. */
15
+ export function thrownQuotaFailure(error: unknown): QuotaFailureCode {
16
+ const name = error && typeof error === "object" ? (error as { name?: unknown }).name : undefined;
17
+ return name === "TimeoutError" ? "timeout" : "transport_error";
18
+ }
@@ -8,7 +8,7 @@ import { captureOAuthAccountSelection, credentialGeneration, getAccountCredentia
8
8
  import { hydrateKiroAccountState, persistKiroAccountState } from "../kiro-account-state-disk";
9
9
  import { kiroProbeCurrent, kiroProbeIdentity } from "./kiro-account-probe";
10
10
  import { fetchMuseKeyQuotaSnapshot } from "../muse-key-quota";
11
- import { readAnthropicUsageQuota } from "../claude-cli-usage";
11
+ import { probeAnthropicUsageQuota, type AnthropicUsageProbe } from "../claude-cli-usage";
12
12
  import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "../xai-transport";
13
13
  import {
14
14
  commitKiroAccountUsageState,
@@ -227,7 +227,7 @@ export async function fetchXaiQuota(provider: string, context: { accessToken: st
227
227
  }
228
228
  }
229
229
 
230
- const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
230
+ const anthropicUsageInflight = new Map<string, Promise<AnthropicUsageProbe>>();
231
231
 
232
232
  /**
233
233
  * Anthropic per-credential usage.
@@ -256,6 +256,15 @@ export async function fetchAnthropicUsageQuotaForInstance(
256
256
  accessToken: string,
257
257
  requireFreshDispatch = false,
258
258
  ): Promise<ProviderQuota | null> {
259
+ return (await fetchAnthropicUsageProbeForInstance(instance, accessToken, requireFreshDispatch)).quota;
260
+ }
261
+
262
+ /** The same shared flight as `fetchAnthropicUsageQuotaForInstance`, keeping the closed failure reason. */
263
+ export async function fetchAnthropicUsageProbeForInstance(
264
+ instance: AnthropicInstanceId,
265
+ accessToken: string,
266
+ requireFreshDispatch = false,
267
+ ): Promise<AnthropicUsageProbe> {
259
268
  // Recovery evidence must be requested after the claimed cooldown. Joining an older request
260
269
  // can return after the 429 while still describing provider state from before that refusal.
261
270
  const credentialIdentity = getAccountSet(instance)?.accounts.filter(row => row.credential.access === accessToken)
@@ -264,7 +273,7 @@ export async function fetchAnthropicUsageQuotaForInstance(
264
273
  const joinable = requireFreshDispatch ? undefined : anthropicUsageInflight.get(key);
265
274
  if (joinable) return joinable;
266
275
 
267
- const probe = readAnthropicUsageQuota(accessToken).finally(() => {
276
+ const probe = probeAnthropicUsageQuota(accessToken).finally(() => {
268
277
  if (anthropicUsageInflight.get(key) === probe) anthropicUsageInflight.delete(key);
269
278
  });
270
279
  anthropicUsageInflight.set(key, probe);
@@ -29,7 +29,7 @@ export function providerQuotaRoutingBinding(
29
29
  // A key probe must not constrain that later account selection.
30
30
  const entry = getProviderRegistryEntry(name);
31
31
  if (entry && (entry.authKind === "oauth" || entry.authKind === "forward")
32
- && !providerUsesKeyAuthOverride(entry, provider, credential)) return null;
32
+ && !providerUsesKeyAuthOverride(entry, provider)) return null;
33
33
  // Static auth headers can replace or combine with the probed API-key header.
34
34
  // Its semantics belong to the adapter, so it is not provider-wide quota evidence.
35
35
  if (Object.keys(provider.headers ?? {}).some(header =>