@yansigit/opencodex 2.31.3 → 2.33.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 (176) hide show
  1. package/README.md +2 -2
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  4. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +6 -5
  7. package/src/adapters/anthropic.ts +25 -13
  8. package/src/adapters/azure.ts +20 -4
  9. package/src/adapters/base.ts +5 -1
  10. package/src/adapters/command-code.ts +42 -10
  11. package/src/adapters/cursor/live-models.ts +8 -0
  12. package/src/adapters/cursor/live-transport.ts +1 -1
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/protobuf-events.ts +158 -7
  15. package/src/adapters/cursor/protobuf-request.ts +33 -15
  16. package/src/adapters/cursor/request-builder.ts +4 -3
  17. package/src/adapters/cursor/tool-definitions.ts +27 -1
  18. package/src/adapters/cursor/types.ts +4 -3
  19. package/src/adapters/cursor.ts +9 -0
  20. package/src/adapters/google-antigravity-replay.ts +2 -2
  21. package/src/adapters/google-antigravity-wire.ts +7 -0
  22. package/src/adapters/google-errors.ts +6 -2
  23. package/src/adapters/google-http.ts +30 -7
  24. package/src/adapters/google-truncation.ts +5 -0
  25. package/src/adapters/google-wire-compiler.ts +38 -6
  26. package/src/adapters/google.ts +154 -33
  27. package/src/adapters/kiro-tools.ts +20 -9
  28. package/src/adapters/kiro.ts +0 -3
  29. package/src/adapters/openai-chat.ts +9 -0
  30. package/src/adapters/openai-responses.ts +4 -1
  31. package/src/adapters/tool-catalog-nudge.ts +1 -1
  32. package/src/adapters/xai-web-search.ts +7 -2
  33. package/src/bridge.ts +133 -24
  34. package/src/claude/context-windows.ts +16 -9
  35. package/src/cli/dispatch.ts +50 -2
  36. package/src/cli/doctor.ts +26 -13
  37. package/src/cli/help.ts +4 -3
  38. package/src/cli/index.ts +20 -6
  39. package/src/cli/models.ts +13 -3
  40. package/src/cli/observe.ts +20 -5
  41. package/src/cli/provider.ts +8 -1
  42. package/src/cli/registry.ts +7 -5
  43. package/src/cli/status.ts +25 -1
  44. package/src/cli/system-restart-client.ts +1 -1
  45. package/src/cli/usage-report.ts +134 -0
  46. package/src/codex/app-server-processes.ts +3 -1
  47. package/src/codex/auth-api.ts +4 -2
  48. package/src/codex/autostart-health.ts +16 -0
  49. package/src/codex/catalog/aggregation.ts +12 -0
  50. package/src/codex/catalog/effort.ts +42 -12
  51. package/src/codex/catalog/metadata.ts +27 -1
  52. package/src/codex/catalog/model-metadata.ts +593 -0
  53. package/src/codex/catalog/parsing.ts +71 -27
  54. package/src/codex/catalog/provider-fetch.ts +189 -33
  55. package/src/codex/catalog/sync.ts +6 -5
  56. package/src/codex/convergence.ts +5 -0
  57. package/src/codex/desktop-app-restart.ts +342 -0
  58. package/src/codex/history-job.ts +32 -3
  59. package/src/codex/history-manifest.ts +112 -0
  60. package/src/codex/history-migration-guardian.ts +5 -5
  61. package/src/codex/history-provider.ts +825 -247
  62. package/src/codex/history-worker.ts +8 -5
  63. package/src/codex/inject.ts +49 -21
  64. package/src/codex/injected-marker.ts +1 -1
  65. package/src/codex/internal/history-writer.ts +4 -3
  66. package/src/codex/native-profile-startup.ts +157 -27
  67. package/src/codex/native-residue.ts +26 -33
  68. package/src/codex/shim.ts +56 -3
  69. package/src/combos/failover.ts +27 -0
  70. package/src/compatibility/index.ts +26 -0
  71. package/src/compatibility/manifest.ts +253 -0
  72. package/src/compatibility/openai-responses.ts +81 -0
  73. package/src/config/atomic-write.ts +219 -0
  74. package/src/config/paths.ts +40 -0
  75. package/src/config/process-state.ts +308 -0
  76. package/src/config/provider-validation.ts +214 -0
  77. package/src/config.ts +153 -814
  78. package/src/generated/compatibility-version.json +232 -148
  79. package/src/images/loop.ts +37 -6
  80. package/src/images/plan.ts +5 -4
  81. package/src/integrations/ownership-policy.ts +141 -0
  82. package/src/integrations/ownership.ts +10 -0
  83. package/src/integrations/state.ts +44 -5
  84. package/src/integrations/writer.ts +6 -0
  85. package/src/lib/azure-identity.ts +154 -0
  86. package/src/lib/bounded-body.ts +14 -2
  87. package/src/lib/debug.ts +42 -0
  88. package/src/lib/errors.ts +14 -0
  89. package/src/lib/process-control.ts +2 -1
  90. package/src/lib/provider-outbound.ts +45 -33
  91. package/src/lib/provider-tls-profile.ts +309 -0
  92. package/src/lib/proxy-env.ts +49 -0
  93. package/src/lib/redact.ts +10 -1
  94. package/src/lib/state-store-registrations.ts +2 -0
  95. package/src/lib/tool-argument-integers.ts +56 -5
  96. package/src/oauth/antigravity-routing.ts +282 -236
  97. package/src/oauth/callback-server.ts +22 -2
  98. package/src/oauth/command-code.ts +5 -16
  99. package/src/oauth/google-antigravity.ts +42 -5
  100. package/src/oauth/health.ts +1 -1
  101. package/src/oauth/index.ts +15 -3
  102. package/src/oauth/kimi.ts +9 -1
  103. package/src/oauth/open-browser-choice.ts +26 -0
  104. package/src/oauth/store.ts +6 -0
  105. package/src/providers/antigravity-quota.ts +3 -1
  106. package/src/providers/api-keys.ts +2 -1
  107. package/src/providers/auto-compact-budget.ts +65 -0
  108. package/src/providers/derive.ts +4 -0
  109. package/src/providers/key-failover.ts +5 -1
  110. package/src/providers/openai-tiers.ts +5 -0
  111. package/src/providers/provider-id-rewrite.ts +1 -0
  112. package/src/providers/quota.ts +59 -13
  113. package/src/providers/registry.ts +4 -2
  114. package/src/providers/request-pacing.ts +33 -6
  115. package/src/providers/xai-transport.ts +21 -0
  116. package/src/reasoning-effort.ts +19 -2
  117. package/src/responses/apply-patch-envelope.ts +63 -0
  118. package/src/responses/custom-tool-compat.ts +132 -38
  119. package/src/responses/google-provider-options.ts +36 -0
  120. package/src/responses/namespace-tool-compat.ts +84 -4
  121. package/src/responses/parser.ts +14 -2
  122. package/src/responses/provider-opaque-metadata.ts +3 -3
  123. package/src/responses/reasoning-replay-cache.ts +81 -3
  124. package/src/responses/schema.ts +37 -0
  125. package/src/responses/state.ts +94 -4
  126. package/src/router.ts +8 -2
  127. package/src/server/auth-cors.ts +37 -7
  128. package/src/server/images.ts +19 -35
  129. package/src/server/index.ts +102 -21
  130. package/src/server/local-management-read-client.ts +1 -1
  131. package/src/server/local-provider-reload-client.ts +1 -1
  132. package/src/server/management/agent-settings-routes.ts +206 -16
  133. package/src/server/management/combo-routes.ts +6 -0
  134. package/src/server/management/config-routes.ts +35 -6
  135. package/src/server/management/context.ts +1 -1
  136. package/src/server/management/logs-usage-routes.ts +27 -6
  137. package/src/server/management/model-routes.ts +8 -4
  138. package/src/server/management/model-rows.ts +4 -0
  139. package/src/server/management/native-integration-routes.ts +2 -1
  140. package/src/server/management/oauth-account-routes.ts +25 -4
  141. package/src/server/management/provider-capability-config.ts +1 -1
  142. package/src/server/management/provider-routes.ts +113 -15
  143. package/src/server/management/routing-profile-routes.ts +3 -0
  144. package/src/server/management/system-restart.ts +1 -1
  145. package/src/server/port-reclaim.ts +1 -1
  146. package/src/server/proxy-liveness.ts +2 -1
  147. package/src/server/request-log-conversation.ts +30 -0
  148. package/src/server/request-log.ts +21 -0
  149. package/src/server/responses/agent-task-recovery.ts +1 -1
  150. package/src/server/responses/codex-auth-error.ts +55 -0
  151. package/src/server/responses/combo-stream-preflight.ts +171 -0
  152. package/src/server/responses/compact.ts +36 -22
  153. package/src/server/responses/core.ts +584 -247
  154. package/src/server/responses/empty-completion-guard.ts +35 -6
  155. package/src/server/responses/fetch-helpers.ts +20 -102
  156. package/src/server/responses/v2-native-parent-override.ts +59 -0
  157. package/src/server/responses/ws-upstream.ts +75 -2
  158. package/src/server/responses-custom-tool-repair.ts +41 -5
  159. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  160. package/src/service.ts +9 -5
  161. package/src/types/config.ts +16 -1
  162. package/src/types/provider.ts +16 -0
  163. package/src/types/request.ts +34 -1
  164. package/src/types/tools.ts +114 -11
  165. package/src/types.ts +7 -1
  166. package/src/update/index.ts +5 -4
  167. package/src/update/job.ts +3 -1
  168. package/src/update/transactional-install.mjs +8 -1
  169. package/src/usage/log.ts +16 -8
  170. package/src/usage/summary.ts +201 -8
  171. package/src/vision/describe.ts +18 -13
  172. package/src/web-search/executor.ts +10 -3
  173. package/src/web-search/gemini-executor.ts +6 -4
  174. package/src/web-search/loop.ts +42 -6
  175. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  176. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
@@ -1,286 +1,210 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { getAccountSet } from "./store";
2
- import {
3
- bindSessionAffinity,
4
- buildSessionKeyFromParts,
5
- clearAffinityState,
6
- clearSessionAffinityForAccount,
7
- getSessionAffinity,
8
- normalizeAffinityComponent,
9
- touchSessionAffinity,
10
- } from "../routing/account-pool";
11
-
12
- /**
13
- * Process-local Antigravity account health (cooldowns). Stored in an in-memory
14
- * `Map<string, AntigravityAccountHealth>` for the lifetime of this process only —
15
- * cooldowns reset on restart and are not shared across workers.
16
- */
17
- export type AntigravityCooldownReason = "rate_limited" | "quota_exhausted" | "geo_blocked";
18
-
19
- const POOL_KEY_ANTIGRAVITY = "google-antigravity";
20
- /** Short rate-limit stick-wait: retry the same account instead of hopping. Never wait on quota/geo. */
21
- export const ANTIGRAVITY_STICK_WAIT_MAX_MS = 5_000;
22
-
23
- const DEFAULT_RATE_LIMITED_COOLDOWN_MS = 5_000;
24
- const MAX_RATE_LIMITED_COOLDOWN_MS = 60_000;
25
- const DEFAULT_QUOTA_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000;
26
- const MAX_QUOTA_EXHAUSTED_COOLDOWN_MS = 7 * 24 * 60 * 60_000;
27
- const GEO_BLOCKED_COOLDOWN_MS = 24 * 60 * 60_000;
3
+ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
4
+
5
+ const PROVIDER = "google-antigravity";
6
+ const DEFAULT_COOLDOWN_MS = 60_000;
7
+ const MAX_COOLDOWN_MS = 15 * 60_000;
8
+ const MAX_SHORT_RETRY_MS = 5_000;
9
+ const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
10
+ const MAX_AFFINITY_ENTRIES = 2_000;
11
+ const MAX_AFFINITY_COMPONENT_LENGTH = 128;
12
+
13
+ type CooldownSource = "retry-after" | "default" | "synthetic";
14
+ export type AntigravityCooldownKind = "rate-limit" | "quota" | "geoblock";
15
+ export type AntigravitySyntheticFailure = AntigravityCooldownKind;
16
+
17
+ export interface AntigravityProviderError {
18
+ code?: string;
19
+ status?: number;
20
+ message?: string;
21
+ }
28
22
 
29
- type AntigravityAccountHealth = {
23
+ interface AccountHealth {
30
24
  cooldownUntil: number;
31
- reason: AntigravityCooldownReason;
32
- };
33
-
34
- const accountHealth = new Map<string, AntigravityAccountHealth>();
35
-
36
- function positiveDurationOrDefault(
37
- durationMs: number | undefined,
38
- defaultMs: number,
39
- maxMs?: number,
40
- ): number {
41
- if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) {
42
- return defaultMs;
43
- }
44
- return maxMs === undefined ? durationMs : Math.min(durationMs, maxMs);
25
+ cooldownSource: CooldownSource;
26
+ cooldownKind: AntigravityCooldownKind;
45
27
  }
46
28
 
47
- function cooldownDurationMs(
48
- reason: AntigravityCooldownReason,
49
- retryAfterMs: number | undefined,
50
- ): number {
51
- switch (reason) {
52
- case "rate_limited":
53
- return positiveDurationOrDefault(
54
- retryAfterMs,
55
- DEFAULT_RATE_LIMITED_COOLDOWN_MS,
56
- MAX_RATE_LIMITED_COOLDOWN_MS,
57
- );
58
- case "quota_exhausted":
59
- return positiveDurationOrDefault(
60
- retryAfterMs,
61
- DEFAULT_QUOTA_EXHAUSTED_COOLDOWN_MS,
62
- MAX_QUOTA_EXHAUSTED_COOLDOWN_MS,
63
- );
64
- case "geo_blocked":
65
- return GEO_BLOCKED_COOLDOWN_MS;
66
- }
29
+ interface AffinityEntry {
30
+ accountId: string;
31
+ lastUsedAt: number;
67
32
  }
68
33
 
69
- export function recordAntigravityCooldown(
70
- accountId: string,
71
- reason: AntigravityCooldownReason,
72
- retryAfterMs?: number,
73
- now = Date.now(),
74
- ): void {
75
- const cooldownUntil = now + cooldownDurationMs(reason, retryAfterMs);
76
- const current = accountHealth.get(accountId);
77
- if (!current || current.cooldownUntil < cooldownUntil) {
78
- accountHealth.set(accountId, { cooldownUntil, reason });
79
- }
80
- }
34
+ const accountHealth = new Map<string, AccountHealth>();
35
+ const sessionAffinity = new Map<string, AffinityEntry>();
81
36
 
82
- export function getAntigravityAccountCooldown(
83
- accountId: string,
84
- now = Date.now(),
85
- ): { cooldownUntil: number; reason: AntigravityCooldownReason } | undefined {
86
- const health = accountHealth.get(accountId);
87
- if (!health) return undefined;
88
- if (health.cooldownUntil <= now) {
89
- accountHealth.delete(accountId);
90
- return undefined;
91
- }
92
- return { cooldownUntil: health.cooldownUntil, reason: health.reason };
37
+ function trimmed(value: string | null | undefined): string {
38
+ return value?.trim() ?? "";
93
39
  }
94
40
 
95
- export function isAntigravityAccountInCooldown(accountId: string, now = Date.now()): boolean {
96
- const health = accountHealth.get(accountId);
97
- if (!health) return false;
98
- if (health.cooldownUntil <= now) {
99
- accountHealth.delete(accountId);
100
- return false;
101
- }
102
- return true;
41
+ function affinityKey(value: string | null | undefined): string {
42
+ const candidate = trimmed(value);
43
+ if (!candidate) return "";
44
+ return candidate.length <= MAX_AFFINITY_COMPONENT_LENGTH
45
+ ? candidate
46
+ : createHash("sha256").update(candidate).digest("hex");
103
47
  }
104
48
 
105
- export function nextAntigravityAccount(
106
- accountIds: string[],
107
- activeId: string | undefined,
108
- now = Date.now(),
109
- ): string | undefined {
110
- if (accountIds.length === 0) return undefined;
111
-
112
- const activeIndex = activeId === undefined ? -1 : accountIds.indexOf(activeId);
113
- const startIndex = activeIndex < 0 ? 0 : activeIndex + 1;
114
- for (let offset = 0; offset < accountIds.length; offset += 1) {
115
- const accountId = accountIds[(startIndex + offset) % accountIds.length]!;
116
- if (activeId !== undefined && accountId === activeId) continue;
117
- if (!isAntigravityAccountInCooldown(accountId, now)) return accountId;
49
+ function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
50
+ const text = value?.trim();
51
+ if (!text) return undefined;
52
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
53
+ const seconds = Number(text);
54
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(Math.ceil(seconds * 1000), MAX_COOLDOWN_MS);
118
55
  }
119
- return undefined;
56
+ const timestamp = Date.parse(text);
57
+ if (!Number.isFinite(timestamp)) return undefined;
58
+ const delay = timestamp - now;
59
+ return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
120
60
  }
121
61
 
122
- export function sweepExpiredAntigravityRoutingHealth(now = Date.now()): number {
123
- let removed = 0;
124
- for (const [accountId, health] of accountHealth) {
125
- if (health.cooldownUntil > now) continue;
126
- accountHealth.delete(accountId);
127
- removed += 1;
128
- }
129
- return removed;
62
+ export function antigravitySessionKeyFromParts(input: {
63
+ clientThreadId?: string | null;
64
+ sessionIdHeader?: string | null;
65
+ threadIdHeader?: string | null;
66
+ promptCacheKey?: string | null;
67
+ }): string | null {
68
+ const preferred = [input.clientThreadId, input.sessionIdHeader, input.threadIdHeader, input.promptCacheKey]
69
+ .map(trimmed)
70
+ .find(Boolean);
71
+ if (!preferred) return null;
72
+ return affinityKey(preferred);
130
73
  }
131
74
 
132
- export function clearAntigravityAccountCooldown(accountId: string): void {
133
- accountHealth.delete(accountId);
75
+ function pruneExpiredAffinity(now: number): void {
76
+ for (const [key, entry] of sessionAffinity) {
77
+ if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key);
78
+ }
79
+ if (sessionAffinity.size <= MAX_AFFINITY_ENTRIES) return;
80
+ const entries = [...sessionAffinity.entries()].sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
81
+ for (let i = 0; i < sessionAffinity.size - MAX_AFFINITY_ENTRIES; i += 1) {
82
+ sessionAffinity.delete(entries[i]![0]);
83
+ }
134
84
  }
135
85
 
136
- /** Test helper: reset process-local cooldown state between cases. */
137
- export function clearAntigravityRoutingHealthForTests(): void {
86
+ export function clearAntigravityRoutingState(): void {
138
87
  accountHealth.clear();
88
+ sessionAffinity.clear();
139
89
  }
140
90
 
141
- export function isAntigravityRateLimitStickWait(accountId: string, now = Date.now()): boolean {
142
- const health = getAntigravityAccountCooldown(accountId, now);
143
- if (!health || health.reason !== "rate_limited") return false;
144
- const remaining = health.cooldownUntil - now;
145
- return remaining > 0 && remaining <= ANTIGRAVITY_STICK_WAIT_MAX_MS;
91
+ export function antigravitySessionAffinitySizeForTests(): number {
92
+ return sessionAffinity.size;
146
93
  }
147
94
 
148
- /** Milliseconds to wait before retrying the same account on 429, or null when failover should run. */
149
- export function antigravity429StickWaitMs(accountId: string, now = Date.now()): number | null {
150
- if (!isAntigravityRateLimitStickWait(accountId, now)) return null;
151
- const health = getAntigravityAccountCooldown(accountId, now);
152
- if (!health) return null;
153
- const remaining = health.cooldownUntil - now;
154
- return remaining > 0 ? remaining : null;
95
+ export function bindAntigravitySessionAffinity(
96
+ sessionKey: string | null | undefined,
97
+ accountId: string,
98
+ now = Date.now(),
99
+ ): void {
100
+ const key = affinityKey(sessionKey);
101
+ const account = trimmed(accountId);
102
+ if (!key || !account) return;
103
+ sessionAffinity.set(key, { accountId: account, lastUsedAt: now });
104
+ pruneExpiredAffinity(now);
155
105
  }
156
106
 
157
- export function isAntigravityAccountEligible(accountId: string, now = Date.now()): boolean {
158
- if (!isAntigravityAccountInCooldown(accountId, now)) return true;
159
- return isAntigravityRateLimitStickWait(accountId, now);
107
+ export function clearAntigravitySessionAffinityForAccount(accountId: string): void {
108
+ for (const [key, entry] of sessionAffinity) {
109
+ if (entry.accountId === accountId) sessionAffinity.delete(key);
110
+ }
160
111
  }
161
112
 
162
- export function getEligibleAntigravityAccounts(now = Date.now()): string[] {
163
- const set = getAccountSet(POOL_KEY_ANTIGRAVITY);
164
- if (!set) return [];
165
- return set.accounts
166
- .filter(account => isAntigravityAccountEligible(account.id, now))
167
- .map(account => account.id);
113
+ export function clearAntigravitySessionAffinity(): void {
114
+ sessionAffinity.clear();
168
115
  }
169
116
 
170
- export type AntigravityAccountSelectionReason =
171
- | "affinity"
172
- | "active"
173
- | "failover"
174
- | "none"
175
- | "all-cooled";
176
-
177
- export interface AntigravityAccountSelection {
178
- accountId: string | null;
179
- reason: AntigravityAccountSelectionReason;
117
+ /**
118
+ * Manual selection resets session affinity and clears any stale cooldown
119
+ * on the manually chosen account so requests immediately honor the user's choice.
120
+ */
121
+ export function resetAntigravityRoutingForManualSelection(accountId: string): void {
122
+ sessionAffinity.clear();
123
+ clearAntigravityAccountCooldown(accountId);
180
124
  }
181
125
 
182
- function bindAntigravityAffinityIfPossible(
183
- sessionKey: string | null | undefined,
184
- accountId: string,
185
- now: number,
186
- ): void {
187
- bindSessionAffinity(POOL_KEY_ANTIGRAVITY, sessionKey, accountId, now);
126
+ /** Remove only the local routing state owned by one deleted Antigravity account. */
127
+ export function clearAntigravityRoutingStateForAccount(accountId: string): void {
128
+ clearAntigravityAccountCooldown(accountId);
129
+ clearAntigravitySessionAffinityForAccount(accountId);
188
130
  }
189
131
 
190
- /**
191
- * Failover-only account pick: stick bound sessions, default new sessions to the store active
192
- * account, and hop only when the chosen account is cooled (except short rate-limit stick-wait).
193
- * Does not call setActiveAccount.
194
- */
195
- export function resolveAntigravityAccountForSession(
196
- sessionKey: string | null | undefined,
132
+ export function getAntigravityAccountHealthSnapshot(
133
+ accountId: string,
197
134
  now = Date.now(),
198
- ): AntigravityAccountSelection {
199
- const set = getAccountSet(POOL_KEY_ANTIGRAVITY);
200
- if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
201
-
202
- const accountIds = set.accounts.map(account => account.id);
203
- const activeId = set.activeAccountId;
204
- const key = normalizeAffinityComponent(sessionKey);
205
-
206
- if (key) {
207
- const affined = getSessionAffinity(POOL_KEY_ANTIGRAVITY, key, now);
208
- if (affined) {
209
- if (isAntigravityAccountEligible(affined.accountId, now)) {
210
- touchSessionAffinity(POOL_KEY_ANTIGRAVITY, key, now);
211
- return { accountId: affined.accountId, reason: "affinity" };
212
- }
213
- const next = nextAntigravityAccount(accountIds, affined.accountId, now);
214
- if (next) {
215
- bindAntigravityAffinityIfPossible(sessionKey, next, now);
216
- return { accountId: next, reason: "failover" };
217
- }
218
- clearSessionAffinityForAccount(POOL_KEY_ANTIGRAVITY, affined.accountId);
219
- const anyCooled = accountIds.some(id => !isAntigravityAccountEligible(id, now));
220
- return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
221
- }
222
- }
223
-
224
- if (activeId && isAntigravityAccountEligible(activeId, now)) {
225
- bindAntigravityAffinityIfPossible(sessionKey, activeId, now);
226
- return { accountId: activeId, reason: "active" };
227
- }
228
-
229
- const next = nextAntigravityAccount(accountIds, activeId, now);
230
- if (next) {
231
- bindAntigravityAffinityIfPossible(sessionKey, next, now);
232
- return { accountId: next, reason: "failover" };
135
+ ): { cooldownUntil: number; cooldownSource: CooldownSource; cooldownKind: AntigravityCooldownKind } | null {
136
+ const health = accountHealth.get(accountId);
137
+ if (!health) return null;
138
+ if (health.cooldownUntil <= now) {
139
+ accountHealth.delete(accountId);
140
+ return null;
233
141
  }
234
-
235
- const anyCooled = accountIds.some(id => !isAntigravityAccountEligible(id, now));
236
- return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
142
+ return { ...health };
237
143
  }
238
144
 
239
- export function bindAntigravitySessionAffinity(
240
- sessionKey: string | null | undefined,
145
+ export function recordAntigravityCooldown(
241
146
  accountId: string,
147
+ retryAfterHeader?: string | null,
242
148
  now = Date.now(),
243
- ): void {
244
- bindAntigravityAffinityIfPossible(sessionKey, accountId, now);
149
+ cooldownKind: AntigravityCooldownKind = "rate-limit",
150
+ source?: "default" | "retry-after" | "synthetic",
151
+ ): number {
152
+ const delay = parseRetryAfterMs(retryAfterHeader, now) ?? (cooldownKind === "geoblock" ? MAX_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
153
+ const targetUntil = now + delay;
154
+ const cooldownSource = source ?? (retryAfterHeader?.trim() ? "retry-after" : "default");
155
+ const existing = accountHealth.get(accountId);
156
+ if (existing && existing.cooldownUntil > now) {
157
+ const effectiveUntil = Math.max(existing.cooldownUntil, targetUntil);
158
+ const effectiveKind = (existing.cooldownKind === "geoblock" || cooldownKind === "geoblock")
159
+ ? "geoblock"
160
+ : (cooldownKind === "quota" || existing.cooldownKind === "quota")
161
+ ? "quota"
162
+ : "rate-limit";
163
+ const effectiveSource = (source === "synthetic" || existing.cooldownSource === "synthetic")
164
+ ? "synthetic"
165
+ : (retryAfterHeader?.trim() ? "retry-after" : existing.cooldownSource);
166
+ accountHealth.set(accountId, {
167
+ cooldownUntil: effectiveUntil,
168
+ cooldownSource: effectiveSource,
169
+ cooldownKind: effectiveKind,
170
+ });
171
+ sweepExpiredOnWrite(now);
172
+ return effectiveUntil;
173
+ }
174
+ accountHealth.set(accountId, {
175
+ cooldownUntil: targetUntil,
176
+ cooldownSource,
177
+ cooldownKind,
178
+ });
179
+ sweepExpiredOnWrite(now);
180
+ return targetUntil;
245
181
  }
246
182
 
247
- /**
248
- * Pick the next Antigravity account after a rate-limit 429. Cooldown is recorded upstream
249
- * (google-http). Does not promote the global active account.
250
- */
251
- export function rotateAntigravityAccountOn429(
252
- failedAccountId: string,
253
- sessionKey: string | null | undefined,
254
- now = Date.now(),
255
- ): string | null {
256
- if (antigravity429StickWaitMs(failedAccountId, now) !== null) return null;
257
-
258
- const set = getAccountSet(POOL_KEY_ANTIGRAVITY);
259
- if (!set) return null;
260
- const accountIds = set.accounts.map(account => account.id);
261
-
262
- clearSessionAffinityForAccount(POOL_KEY_ANTIGRAVITY, failedAccountId);
263
-
264
- const next = nextAntigravityAccount(accountIds, failedAccountId, now);
265
- if (!next) return null;
183
+ export function clearAntigravityAccountCooldown(accountId: string): boolean {
184
+ return accountHealth.delete(accountId);
185
+ }
266
186
 
267
- bindAntigravityAffinityIfPossible(sessionKey, next, now);
268
- return next;
187
+ export function isAntigravityAccountInCooldown(accountId: string, now = Date.now()): boolean {
188
+ return getAntigravityAccountHealthSnapshot(accountId, now) !== null;
269
189
  }
270
190
 
271
- /** Test / logout helper. */
272
- export function clearAntigravityAccountPoolState(): void {
273
- clearAffinityState(POOL_KEY_ANTIGRAVITY);
191
+ export function sweepExpiredAntigravityRoutingHealth(now = Date.now()): number {
192
+ let removed = 0;
193
+ for (const [accountId, health] of accountHealth) {
194
+ if (health.cooldownUntil <= now) {
195
+ accountHealth.delete(accountId);
196
+ removed += 1;
197
+ }
198
+ }
199
+ return removed;
274
200
  }
275
201
 
276
- export function antigravitySessionKeyFromParts(input: {
277
- sessionIdHeader?: string | null;
278
- threadIdHeader?: string | null;
279
- promptCacheKey?: string | null;
280
- clientThreadId?: string | null;
281
- promptCacheKeyIsSharedCohort?: boolean;
282
- }): string | null {
283
- return buildSessionKeyFromParts(input);
202
+ export function retryableAntigravity429DelayMs(
203
+ retryAfterHeader: string | null | undefined,
204
+ now = Date.now(),
205
+ ): number | null {
206
+ const delay = parseRetryAfterMs(retryAfterHeader, now);
207
+ return delay !== undefined && delay <= MAX_SHORT_RETRY_MS ? delay : null;
284
208
  }
285
209
 
286
210
  export const ANTIGRAVITY_MISSING_PROJECT_MESSAGE =
@@ -314,3 +238,125 @@ export function bindAntigravityProject<T extends { project?: string }>(
314
238
  }
315
239
  return { ok: true, provider: { ...provider, project } };
316
240
  }
241
+
242
+ export type AntigravityAccountSelectionReason =
243
+ | "affinity"
244
+ | "active"
245
+ | "active-cooled"
246
+ | "active-needs-reauth"
247
+ | "missing-affinity"
248
+ | "none";
249
+
250
+ export interface AntigravityAccountSelection {
251
+ accountId: string | null;
252
+ reason: AntigravityAccountSelectionReason;
253
+ cooldownUntil?: number;
254
+ cooldownKind?: AntigravityCooldownKind;
255
+ }
256
+
257
+ export function resolveAntigravityAccountForSession(
258
+ sessionKey: string | null | undefined,
259
+ now = Date.now(),
260
+ ): AntigravityAccountSelection {
261
+ pruneExpiredAffinity(now);
262
+ const key = affinityKey(sessionKey);
263
+ const set = getAccountSet(PROVIDER);
264
+ if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
265
+
266
+ if (key) {
267
+ const bound = sessionAffinity.get(key);
268
+ if (bound) {
269
+ const account = set.accounts.find(candidate => candidate.id === bound.accountId);
270
+ if (!account) {
271
+ sessionAffinity.delete(key);
272
+ } else {
273
+ bound.lastUsedAt = now;
274
+ if (account.needsReauth === true) return { accountId: account.id, reason: "active-needs-reauth" };
275
+ return { accountId: account.id, reason: "affinity", ...(getAntigravityAccountHealthSnapshot(account.id, now) ?? {}) };
276
+ }
277
+ }
278
+ }
279
+
280
+ const account = set.accounts.find(candidate => candidate.id === set.activeAccountId);
281
+ if (!account) return { accountId: null, reason: "none" };
282
+ if (account.needsReauth === true) return { accountId: account.id, reason: "active-needs-reauth" };
283
+ const health = getAntigravityAccountHealthSnapshot(account.id, now);
284
+ if (key) bindAntigravitySessionAffinity(key, account.id, now);
285
+ return {
286
+ accountId: account.id,
287
+ reason: health ? "active-cooled" : "active",
288
+ ...(health ?? {}),
289
+ };
290
+ }
291
+
292
+ const RATE_LIMIT_CODES = new Set(["RATE_LIMIT_EXCEEDED", "TOO_MANY_REQUESTS"]);
293
+ const QUOTA_CODES = new Set(["QUOTA_EXCEEDED"]);
294
+ const GEO_CODES = new Set(["LOCATION_NOT_SUPPORTED", "REGION_NOT_SUPPORTED", "GEO_BLOCKED"]);
295
+
296
+ export function normalizeAntigravityProviderError(value: unknown): AntigravityProviderError | null {
297
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
298
+ const record = value as Record<string, unknown>;
299
+ // Google JSON errors use `{ code: <HTTP number>, status: <enum string>, message }`.
300
+ // Keep accepting the internal `{ code: <enum>, status: <HTTP number> }` shape for
301
+ // existing callers and fixtures, but normalize both to one observer contract.
302
+ const numericCode = typeof record.code === "number" && Number.isInteger(record.code) ? record.code : undefined;
303
+ const numericStatus = typeof record.status === "number" && Number.isInteger(record.status) ? record.status : undefined;
304
+ const enumCode = typeof record.code === "string" ? record.code.trim().toUpperCase() : undefined;
305
+ const enumStatus = typeof record.status === "string" ? record.status.trim().toUpperCase() : undefined;
306
+ const code = enumStatus ?? enumCode;
307
+ const status = numericCode ?? numericStatus;
308
+ const message = typeof record.message === "string" ? record.message.trim().slice(0, 512) : undefined;
309
+ if (!code && status === undefined) return null;
310
+ return {
311
+ ...(code ? { code } : {}),
312
+ ...(status !== undefined ? { status } : {}),
313
+ ...(message ? { message } : {}),
314
+ };
315
+ }
316
+
317
+ export function classifyAntigravityProviderError(value: unknown): AntigravitySyntheticFailure | null {
318
+ const error = normalizeAntigravityProviderError(value);
319
+ if (!error) return null;
320
+ const message = error.message?.toLowerCase() ?? "";
321
+ if (QUOTA_CODES.has(error.code ?? "") || (error.code === "RESOURCE_EXHAUSTED" && /quota|resource[ _-]?exhausted/.test(message))) return "quota";
322
+ if (error.status === 429 || RATE_LIMIT_CODES.has(error.code ?? "") || (error.code === "RESOURCE_EXHAUSTED" && /rate[- ]limit|too many requests/.test(message))) {
323
+ return "rate-limit";
324
+ }
325
+ if (GEO_CODES.has(error.code ?? "") || (
326
+ (error.status === 403 || error.code === "PERMISSION_DENIED")
327
+ && /(?:location|country|region).{0,48}(?:not supported|unavailable|blocked|forbidden)|(?:not supported|unavailable|blocked|forbidden).{0,48}(?:location|country|region)/.test(message)
328
+ )) return "geoblock";
329
+ return null;
330
+ }
331
+
332
+ export function recordAntigravitySyntheticFailure(
333
+ accountId: string,
334
+ payload: unknown,
335
+ now = Date.now(),
336
+ ): AntigravitySyntheticFailure | null {
337
+ const failure = classifyAntigravityProviderError(payload);
338
+ if (!failure) return null;
339
+ const delay = failure === "geoblock" ? MAX_COOLDOWN_MS : DEFAULT_COOLDOWN_MS;
340
+ const targetUntil = now + delay;
341
+ const existing = accountHealth.get(accountId);
342
+ if (existing && existing.cooldownUntil > now) {
343
+ const effectiveUntil = Math.max(existing.cooldownUntil, targetUntil);
344
+ const effectiveKind = (existing.cooldownKind === "geoblock" || failure === "geoblock")
345
+ ? "geoblock"
346
+ : (failure === "quota" || existing.cooldownKind === "quota")
347
+ ? "quota"
348
+ : "rate-limit";
349
+ accountHealth.set(accountId, {
350
+ cooldownUntil: effectiveUntil,
351
+ cooldownSource: "synthetic",
352
+ cooldownKind: effectiveKind,
353
+ });
354
+ return failure;
355
+ }
356
+ accountHealth.set(accountId, {
357
+ cooldownUntil: targetUntil,
358
+ cooldownSource: "synthetic",
359
+ cooldownKind: failure,
360
+ });
361
+ return failure;
362
+ }
@@ -276,10 +276,30 @@ export function parseCallbackInput(input: string): { kind: "url" | "query" | "ra
276
276
 
277
277
  try {
278
278
  const url = new URL(value);
279
+ // Also read the fragment. No provider configured here returns one — every
280
+ // OAuthCallbackFlow asks for response_type=code without response_mode, so
281
+ // the parameters land in the query — but a full URL whose parameters sit in
282
+ // the hash parses as a valid URL with no code, and reading only the query
283
+ // rejects it as "no authorization code found in input". This is the one
284
+ // place a fragment-returning provider would land, and the raw branch below
285
+ // already understands `code#state`.
286
+ //
287
+ // The query wins as a WHOLE when it carries the response, and the two
288
+ // fields are never mixed across collections. Reading `code` and `state`
289
+ // independently would accept `?state=<expected>#code=<other>` — one
290
+ // response assembled from two sources — which is exactly the confusion a
291
+ // state check exists to prevent. An authorization response arrives in one
292
+ // place; treat it that way.
293
+ //
294
+ // Only `code` and `state` are read — never a token. This repo does not
295
+ // implement the implicit grant and a paste field must not become the place
296
+ // it appears.
297
+ const fragment = new URLSearchParams(url.hash.replace(/^#/, ""));
298
+ const source = url.searchParams.has("code") ? url.searchParams : fragment;
279
299
  return {
280
300
  kind: "url",
281
- code: url.searchParams.get("code") ?? undefined,
282
- state: url.searchParams.get("state") ?? undefined,
301
+ code: source.get("code") ?? undefined,
302
+ state: source.get("state") ?? undefined,
283
303
  };
284
304
  } catch {
285
305
  // Not a URL - check for query string format
@@ -1,7 +1,6 @@
1
1
  import type { OAuthController, OAuthCredentials } from "./types";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { isAddrInUse } from "../server/ports";
5
4
  import { parseCallbackInput } from "./callback-server";
6
5
 
7
6
  const COMMAND_CODE_STUDIO_URL = "https://commandcode.ai";
@@ -115,21 +114,11 @@ function createCallbackServer(state: string): {
115
114
  return Response.json({ success: false, error: message }, { status: 400, headers });
116
115
  }
117
116
  };
118
- const create = (port: number): Array<ReturnType<typeof Bun.serve>> => {
119
- // The advertised callback host is `127.0.0.1`; on Windows `localhost` commonly resolves to `::1`
120
- // first, so also bind the IPv6 loopback best-effort (mirrors the shared OAuthCallbackFlow).
121
- const servers = [Bun.serve({ hostname: "127.0.0.1", port, fetch })];
122
- try {
123
- servers.push(Bun.serve({ hostname: "::1", port: servers[0].port, fetch }));
124
- } catch (error) {
125
- if (isAddrInUse(error)) {
126
- for (const server of servers) server.stop(true);
127
- throw error;
128
- }
129
- // IPv6 unsupported (EAFNOSUPPORT etc.) degrades to the IPv4-only listener.
130
- }
131
- return servers;
132
- };
117
+ const create = (port: number): Array<ReturnType<typeof Bun.serve>> => [
118
+ // The advertised callback host is explicitly IPv4, so an IPv6 listener is
119
+ // unnecessary and can conflict with this socket on dual-stack runtimes.
120
+ Bun.serve({ hostname: "127.0.0.1", port, fetch }),
121
+ ];
133
122
  try {
134
123
  return { servers: create(COMMAND_CODE_CALLBACK_PORT), callback };
135
124
  } catch {