@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.
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- 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 =
|
|
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)
|
|
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
|
-
|
|
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
|
-
}))
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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:
|
|
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
|
-
/**
|
|
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"
|
|
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 {
|
|
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 {
|
|
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
|
|
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:
|
|
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 {
|
|
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<
|
|
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 =
|
|
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
|
|
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 =>
|