@yansigit/opencodex 2.32.0 → 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.
- package/README.md +2 -2
- package/gui/dist/assets/index-DKLr4LTE.js +102 -0
- package/gui/dist/assets/index-DrSQdTRd.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +6 -5
- package/src/adapters/anthropic.ts +20 -6
- package/src/adapters/azure.ts +20 -4
- package/src/adapters/base.ts +3 -1
- package/src/adapters/command-code.ts +40 -7
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/protobuf-events.ts +158 -7
- package/src/adapters/cursor/protobuf-request.ts +33 -15
- package/src/adapters/cursor/request-builder.ts +4 -3
- package/src/adapters/cursor/tool-definitions.ts +27 -1
- package/src/adapters/cursor/types.ts +4 -3
- package/src/adapters/cursor.ts +9 -0
- package/src/adapters/google-antigravity-replay.ts +2 -2
- package/src/adapters/google-antigravity-wire.ts +7 -0
- package/src/adapters/google-errors.ts +6 -2
- package/src/adapters/google-http.ts +30 -7
- package/src/adapters/google-truncation.ts +5 -0
- package/src/adapters/google-wire-compiler.ts +38 -6
- package/src/adapters/google.ts +148 -26
- package/src/adapters/kiro-tools.ts +20 -9
- package/src/adapters/openai-chat.ts +9 -0
- package/src/adapters/openai-responses.ts +1 -1
- package/src/bridge.ts +112 -9
- package/src/claude/context-windows.ts +16 -9
- package/src/cli/doctor.ts +2 -2
- package/src/cli/index.ts +9 -2
- package/src/cli/provider.ts +6 -0
- package/src/cli/status.ts +23 -0
- package/src/codex/auth-api.ts +4 -2
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +12 -12
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/model-metadata.ts +39 -12
- package/src/codex/catalog/parsing.ts +38 -27
- package/src/codex/catalog/provider-fetch.ts +198 -133
- package/src/codex/catalog/sync.ts +1 -1
- package/src/codex/convergence.ts +5 -0
- package/src/codex/shim.ts +56 -3
- package/src/config/provider-validation.ts +37 -0
- package/src/config.ts +78 -2
- package/src/generated/compatibility-version.json +120 -96
- package/src/images/loop.ts +37 -6
- package/src/lib/azure-identity.ts +154 -0
- package/src/lib/debug.ts +42 -0
- package/src/lib/errors.ts +14 -0
- package/src/lib/provider-outbound.ts +45 -33
- package/src/lib/provider-tls-profile.ts +309 -0
- package/src/lib/proxy-env.ts +49 -0
- package/src/lib/redact.ts +10 -1
- package/src/oauth/antigravity-routing.ts +282 -236
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/command-code.ts +5 -16
- package/src/oauth/google-antigravity.ts +42 -5
- package/src/oauth/index.ts +15 -3
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +6 -0
- package/src/providers/antigravity-quota.ts +3 -1
- package/src/providers/api-keys.ts +2 -1
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/key-failover.ts +5 -1
- package/src/providers/openai-tiers.ts +5 -0
- package/src/providers/provider-id-rewrite.ts +1 -0
- package/src/providers/quota.ts +59 -13
- package/src/providers/registry.ts +3 -1
- package/src/providers/request-pacing.ts +33 -6
- package/src/providers/xai-transport.ts +21 -0
- package/src/responses/google-provider-options.ts +36 -0
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +11 -0
- package/src/responses/provider-opaque-metadata.ts +3 -3
- package/src/responses/schema.ts +37 -0
- package/src/responses/state.ts +94 -4
- package/src/router.ts +8 -2
- package/src/server/auth-cors.ts +28 -0
- package/src/server/images.ts +19 -35
- package/src/server/management/agent-settings-routes.ts +205 -15
- package/src/server/management/combo-routes.ts +6 -0
- package/src/server/management/config-routes.ts +31 -5
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/oauth-account-routes.ts +25 -4
- package/src/server/management/provider-routes.ts +113 -15
- package/src/server/management/routing-profile-routes.ts +3 -0
- package/src/server/request-log.ts +21 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/compact.ts +30 -1
- package/src/server/responses/core.ts +359 -153
- package/src/server/responses/empty-completion-guard.ts +35 -6
- package/src/server/responses/fetch-helpers.ts +18 -5
- package/src/server/responses/v2-native-parent-override.ts +59 -0
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-undeclared-tool-guard.ts +90 -8
- package/src/service.ts +1 -1
- package/src/types/config.ts +16 -1
- package/src/types/provider.ts +16 -0
- package/src/types/request.ts +28 -0
- package/src/types/tools.ts +27 -0
- package/src/types.ts +6 -0
- package/src/web-search/gemini-executor.ts +6 -4
- package/src/web-search/loop.ts +42 -6
- package/gui/dist/assets/index-BG43zwVe.js +0 -102
- package/gui/dist/assets/index-CiSI-jrP.css +0 -1
|
@@ -1,286 +1,210 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { getAccountSet } from "./store";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
+
interface AccountHealth {
|
|
30
24
|
cooldownUntil: number;
|
|
31
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
export function clearAntigravityRoutingHealthForTests(): void {
|
|
86
|
+
export function clearAntigravityRoutingState(): void {
|
|
138
87
|
accountHealth.clear();
|
|
88
|
+
sessionAffinity.clear();
|
|
139
89
|
}
|
|
140
90
|
|
|
141
|
-
export function
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
163
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
accountId
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
240
|
-
sessionKey: string | null | undefined,
|
|
145
|
+
export function recordAntigravityCooldown(
|
|
241
146
|
accountId: string,
|
|
147
|
+
retryAfterHeader?: string | null,
|
|
242
148
|
now = Date.now(),
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
268
|
-
return
|
|
187
|
+
export function isAntigravityAccountInCooldown(accountId: string, now = Date.now()): boolean {
|
|
188
|
+
return getAntigravityAccountHealthSnapshot(accountId, now) !== null;
|
|
269
189
|
}
|
|
270
190
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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:
|
|
282
|
-
state:
|
|
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
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
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 {
|