@bitkyc08/opencodex 2.89.0 → 2.91.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-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -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 +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- 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/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- 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 +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -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/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- 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/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- 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 +240 -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 +8 -10
- 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/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- 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/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- 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/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.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/agent-task-recovery.ts +1 -1
- 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 +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- 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/encrypted-payload.ts +3 -3
- 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 +85 -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/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -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/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -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,240 @@
|
|
|
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
|
+
/** Queued waiters per credential+model identity; one hot identity never refuses another. */
|
|
12
|
+
const MAX_WAITERS_PER_IDENTITY = 128;
|
|
13
|
+
/**
|
|
14
|
+
* Process-wide waiter bound. Model strings come from clients, so identities are not bounded by
|
|
15
|
+
* configuration; this keeps memory and pending requests finite. It is a multiple of the
|
|
16
|
+
* per-identity cap, so no single identity can fill it alone.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_WAITERS_TOTAL = 4 * MAX_WAITERS_PER_IDENTITY;
|
|
19
|
+
const DEFAULT_MAX_WAIT_MS = 60_000; // Existing retry wait budget, not an inferred rate window.
|
|
20
|
+
const MINUTE_MS = 60 * 1000; // Unit named by the observed headers, never an account quota.
|
|
21
|
+
type RecoveryState = { generation: number; probe?: { done: Promise<void>; finish(): void } };
|
|
22
|
+
|
|
23
|
+
/** Match the supported key-auth Chat destination, including one optional trailing slash. */
|
|
24
|
+
export function isCanonicalMistralChat(
|
|
25
|
+
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "baseUrl">,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (provider.adapter !== "openai-chat" || (provider.authMode !== undefined && provider.authMode !== "key")) return false;
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(provider.baseUrl);
|
|
30
|
+
return url.origin === "https://api.mistral.ai" && /^\/v1\/?$/.test(url.pathname)
|
|
31
|
+
&& !url.username && !url.password && !url.search && !url.hash;
|
|
32
|
+
} catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read a nonnegative integer counter; reject missing or malformed values. */
|
|
36
|
+
function count(headers: Headers, name: string): number | undefined {
|
|
37
|
+
const raw = headers.get(name)?.trim();
|
|
38
|
+
if (!raw || !/^\d+$/.test(raw)) return undefined;
|
|
39
|
+
const value = Number(raw);
|
|
40
|
+
return Number.isSafeInteger(value) ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read the server delay as seconds or an HTTP date; ignore invalid hints. */
|
|
44
|
+
function retryDelay(headers: Headers, now: number): number | undefined {
|
|
45
|
+
const raw = headers.get("retry-after")?.trim();
|
|
46
|
+
if (!raw) return undefined;
|
|
47
|
+
const value = /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - now;
|
|
48
|
+
return Number.isFinite(value) ? Math.max(0, value) : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Classify measured refusal evidence only; never predict a future cache hit or token charge. */
|
|
52
|
+
export function mistralMinuteRefusal(headers: Headers): "temporary" | "charge-above-allowance" | "unknown" {
|
|
53
|
+
const month = count(headers, "x-ratelimit-limit-tokens-month");
|
|
54
|
+
if (month !== undefined && count(headers, "x-ratelimit-remaining-tokens-month") === 0) return "unknown";
|
|
55
|
+
const limit = count(headers, "x-ratelimit-limit-tokens-minute");
|
|
56
|
+
const remaining = count(headers, "x-ratelimit-remaining-tokens-minute");
|
|
57
|
+
const cost = count(headers, "x-ratelimit-tokens-query-cost");
|
|
58
|
+
if (limit !== undefined && limit > 0 && cost !== undefined && cost > limit) return "charge-above-allowance";
|
|
59
|
+
if (limit !== undefined && limit > 0 && remaining !== undefined && remaining <= limit
|
|
60
|
+
&& (remaining === 0 || (cost !== undefined && cost > remaining))) return "temporary";
|
|
61
|
+
const requests = count(headers, "x-ratelimit-limit-req-minute");
|
|
62
|
+
if (requests !== undefined && requests > 0 && count(headers, "x-ratelimit-remaining-req-minute") === 0) return "temporary";
|
|
63
|
+
return "unknown";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Process-local observations, never a fixed account/plan quota or a persisted credential. */
|
|
67
|
+
export class MistralBudgetPacer {
|
|
68
|
+
private readonly deadlines = new Map<string, number>();
|
|
69
|
+
private readonly recoveries = new Map<string, RecoveryState>();
|
|
70
|
+
/** Latest live deadline evicted at capacity; unknown identities wait it out instead of sending early. */
|
|
71
|
+
private evictedUntil = 0;
|
|
72
|
+
/**
|
|
73
|
+
* Parked waiters keyed by identity. A request counts only once it is about to wait, so a held
|
|
74
|
+
* credential or model fills only its own queue and a request that can send now is never answered
|
|
75
|
+
* queue_full (HTTP 429). The map holds identities with live waiters only; the total is capped by
|
|
76
|
+
* MAX_WAITERS_TOTAL.
|
|
77
|
+
*/
|
|
78
|
+
private readonly waiting = new Map<string, number>();
|
|
79
|
+
private waitingTotal = 0;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly now: () => number = Date.now,
|
|
83
|
+
private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void> = sleepWithAbort,
|
|
84
|
+
) {}
|
|
85
|
+
|
|
86
|
+
/** Record server delays and confirmed minute pressure for this credential and model. */
|
|
87
|
+
observe(identity: string, headers: Headers, status = 200): void {
|
|
88
|
+
const tokens = count(headers, "x-ratelimit-remaining-tokens-minute");
|
|
89
|
+
const requests = count(headers, "x-ratelimit-remaining-req-minute");
|
|
90
|
+
// Healthy remaining counters never predict the next request's cache/charge.
|
|
91
|
+
if (status !== 429 && tokens !== 0 && requests !== 0) return;
|
|
92
|
+
// With no explicit time, activate a conservative minute hold only AFTER a
|
|
93
|
+
// refusal establishes pressure. Healthy responses never predict the next cost.
|
|
94
|
+
const delay = retryDelay(headers, this.now())
|
|
95
|
+
?? (status === 429 && mistralMinuteRefusal(headers) === "temporary" ? MINUTE_MS : undefined);
|
|
96
|
+
if (delay === undefined || delay === 0) return;
|
|
97
|
+
for (const [key, deadline] of this.deadlines) {
|
|
98
|
+
if (deadline <= this.now()) this.deadlines.delete(key);
|
|
99
|
+
}
|
|
100
|
+
if (!this.deadlines.has(identity) && this.deadlines.size >= MAX_IDENTITIES) {
|
|
101
|
+
const [evicted, until] = this.deadlines.entries().next().value!;
|
|
102
|
+
this.evictedUntil = Math.max(this.evictedUntil, until);
|
|
103
|
+
this.deadlines.delete(evicted);
|
|
104
|
+
}
|
|
105
|
+
this.deadlines.set(identity, Math.max(this.deadlines.get(identity) ?? 0, this.now() + delay));
|
|
106
|
+
if (status === 429) {
|
|
107
|
+
if (!this.recoveries.has(identity) && this.recoveries.size >= MAX_IDENTITIES) {
|
|
108
|
+
const idle = [...this.recoveries].find(([, state]) => !state.probe);
|
|
109
|
+
if (!idle) return;
|
|
110
|
+
this.recoveries.delete(idle[0]);
|
|
111
|
+
}
|
|
112
|
+
const state = this.recoveries.get(identity) ?? { generation: 0 };
|
|
113
|
+
state.generation++;
|
|
114
|
+
this.recoveries.set(identity, state);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Wait within the admission budget and return a release callback for one recovery probe. */
|
|
119
|
+
async acquire(identity: string, signal?: AbortSignal, maxWaitMs = DEFAULT_MAX_WAIT_MS): Promise<(success?: boolean) => void> {
|
|
120
|
+
signal?.throwIfAborted();
|
|
121
|
+
const deadline = this.now() + maxWaitMs;
|
|
122
|
+
let parked = false;
|
|
123
|
+
const park = (): void => {
|
|
124
|
+
if (parked) return;
|
|
125
|
+
const queued = this.waiting.get(identity) ?? 0;
|
|
126
|
+
if (queued >= MAX_WAITERS_PER_IDENTITY || this.waitingTotal >= MAX_WAITERS_TOTAL) {
|
|
127
|
+
throw new RequestPacingQueueOverloadError("mistral", "queue_full", 60);
|
|
128
|
+
}
|
|
129
|
+
this.waiting.set(identity, queued + 1);
|
|
130
|
+
this.waitingTotal++;
|
|
131
|
+
parked = true;
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
for (;;) {
|
|
135
|
+
signal?.throwIfAborted();
|
|
136
|
+
// An evicted identity may have re-entered with a shorter delay; the evicted one still binds.
|
|
137
|
+
const live = this.evictedUntil > this.now() ? this.evictedUntil : undefined;
|
|
138
|
+
const own = this.deadlines.get(identity);
|
|
139
|
+
const readyAt = own === undefined ? live : live === undefined ? own : Math.max(own, live);
|
|
140
|
+
if (readyAt !== undefined && readyAt > deadline) {
|
|
141
|
+
const delay = readyAt - this.now();
|
|
142
|
+
throw new RequestPacingQueueOverloadError("mistral", "queue_expired", Math.ceil(delay / 1000));
|
|
143
|
+
}
|
|
144
|
+
if (readyAt !== undefined && readyAt > this.now()) {
|
|
145
|
+
park();
|
|
146
|
+
await this.sleep(readyAt - this.now(), signal);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const state = this.recoveries.get(identity);
|
|
150
|
+
if (!state) { this.deadlines.delete(identity); return () => {}; }
|
|
151
|
+
if (state.probe) {
|
|
152
|
+
const remaining = deadline - this.now();
|
|
153
|
+
if (remaining <= 0) throw new RequestPacingQueueOverloadError("mistral", "queue_expired", 1);
|
|
154
|
+
park();
|
|
155
|
+
const local = new AbortController();
|
|
156
|
+
try {
|
|
157
|
+
await Promise.race([state.probe.done, this.sleep(remaining, AbortSignal.any([local.signal, ...(signal ? [signal] : [])]))]);
|
|
158
|
+
} finally { local.abort(); }
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let finish!: () => void;
|
|
162
|
+
const probe = { done: new Promise<void>(resolve => { finish = resolve; }), finish: () => finish() };
|
|
163
|
+
state.probe = probe;
|
|
164
|
+
const generation = state.generation;
|
|
165
|
+
let released = false;
|
|
166
|
+
return success => {
|
|
167
|
+
if (released) return;
|
|
168
|
+
released = true;
|
|
169
|
+
if (state.probe === probe) delete state.probe;
|
|
170
|
+
// Only this probe can settle recovery; an older concurrent success must
|
|
171
|
+
// not erase a more recent refusal or the server's minimum wait.
|
|
172
|
+
if (success && state.generation === generation) {
|
|
173
|
+
if (this.recoveries.get(identity) === state) this.recoveries.delete(identity);
|
|
174
|
+
if ((this.deadlines.get(identity) ?? 0) <= this.now()) this.deadlines.delete(identity);
|
|
175
|
+
}
|
|
176
|
+
probe.finish();
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
if (parked) {
|
|
181
|
+
this.waitingTotal--;
|
|
182
|
+
const left = (this.waiting.get(identity) ?? 1) - 1;
|
|
183
|
+
if (left > 0) this.waiting.set(identity, left);
|
|
184
|
+
else this.waiting.delete(identity);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const pacer = new MistralBudgetPacer();
|
|
191
|
+
const wrapped = new WeakSet<typeof globalThis.fetch>();
|
|
192
|
+
|
|
193
|
+
/** Wrap canonical Mistral Chat sends with bounded admission and server-delay protection. */
|
|
194
|
+
export function resolveMistralTransport(
|
|
195
|
+
provider: OcxProviderTransport,
|
|
196
|
+
budgetPacer = pacer,
|
|
197
|
+
): OcxProviderTransport {
|
|
198
|
+
if (!isCanonicalMistralChat(provider) || (provider.fetch && wrapped.has(provider.fetch))) return provider;
|
|
199
|
+
const baseFetch = provider.fetch
|
|
200
|
+
?? markEgressTransparentExecutor(((input, init) => configuredOutboundFetch(input, init)) as typeof globalThis.fetch);
|
|
201
|
+
const attemptFetch = (async (input, init) => {
|
|
202
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
203
|
+
if (url.origin !== "https://api.mistral.ai" || url.pathname !== "/v1/chat/completions"
|
|
204
|
+
|| url.search || url.hash || init?.method?.toUpperCase() !== "POST" || typeof init.body !== "string") {
|
|
205
|
+
return baseFetch(input, init);
|
|
206
|
+
}
|
|
207
|
+
// The Chat adapter serializes model first. Read only that bounded prefix rather
|
|
208
|
+
// than parsing/copying a potentially million-token conversation a second time.
|
|
209
|
+
const encodedModel = /^\s*\{\s*"model"\s*:\s*("(?:[^"\\]|\\.)*")\s*[,}]/.exec(init.body.slice(0, 4096))?.[1];
|
|
210
|
+
let model: unknown;
|
|
211
|
+
try { model = encodedModel === undefined ? undefined : JSON.parse(encodedModel); } catch { return baseFetch(input, init); }
|
|
212
|
+
const authorization = new Headers(init.headers).get("authorization");
|
|
213
|
+
if (!authorization || typeof model !== "string" || !model || model.length > 256) return baseFetch(input, init);
|
|
214
|
+
// Bind to the actual send credential, including after pool rotation; keep no raw key.
|
|
215
|
+
const identity = createHash("sha256").update(JSON.stringify([authorization, model])).digest("hex");
|
|
216
|
+
const pacing = provider.requestPacing?.enabled !== false && provider.retryOn429?.respectRetryAfter !== false;
|
|
217
|
+
const release = pacing ? await waitOutsideProviderHeaderDeadline(init,
|
|
218
|
+
signal => budgetPacer.acquire(identity, signal, provider.retryOn429?.maxIntervalMs)) : undefined;
|
|
219
|
+
let response: Response;
|
|
220
|
+
try {
|
|
221
|
+
init.signal?.throwIfAborted();
|
|
222
|
+
response = await baseFetch(input, init);
|
|
223
|
+
if (pacing) budgetPacer.observe(identity, response.headers, response.status);
|
|
224
|
+
} catch (error) { release?.(); throw error; }
|
|
225
|
+
release?.(response.ok);
|
|
226
|
+
const retry = provider.retryOn429;
|
|
227
|
+
if (response.status === 429 && retry?.respectRetryAfter !== false) {
|
|
228
|
+
const delay = retryDelay(response.headers, Date.now());
|
|
229
|
+
if (delay !== undefined && delay > (retry?.maxIntervalMs ?? DEFAULT_MAX_WAIT_MS)) {
|
|
230
|
+
// The shared retry helper caps waits. Stop same-key replays sooner than Mistral permits;
|
|
231
|
+
// another credential may still serve, and the original status/body/Retry-After reach the caller.
|
|
232
|
+
markSameTargetRetryHold(response);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return response;
|
|
236
|
+
}) as typeof globalThis.fetch;
|
|
237
|
+
if (isEgressTransparentExecutor(baseFetch)) markEgressTransparentExecutor(attemptFetch);
|
|
238
|
+
wrapped.add(attemptFetch);
|
|
239
|
+
return { ...provider, fetch: attemptFetch };
|
|
240
|
+
}
|
|
@@ -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);
|
|
@@ -8,6 +8,7 @@ import { asRecord, normalizePercent, normalizeResetAt, readQuotaJson, REQUEST_TI
|
|
|
8
8
|
import { report, type ProviderQuotaReport } from "./report-cache";
|
|
9
9
|
import { quotaCredentialIdentity } from "./account-cache";
|
|
10
10
|
import type { ProviderQuota, ProviderQuotaWindow, QuotaFailureCode } from "../quota-types";
|
|
11
|
+
import { httpQuotaFailure } from "./failure-codes";
|
|
11
12
|
|
|
12
13
|
export function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined {
|
|
13
14
|
return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, {
|
|
@@ -275,13 +276,6 @@ function quotaTransportFailure(error: unknown): QuotaFailureCode {
|
|
|
275
276
|
return "transport_error";
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
function quotaHttpFailure(status: number): QuotaFailureCode {
|
|
279
|
-
if (status >= 300 && status < 400) return "redirect_blocked";
|
|
280
|
-
if (status === 401 || status === 403) return "access_denied";
|
|
281
|
-
if (status === 429) return "rate_limited";
|
|
282
|
-
return "upstream_error";
|
|
283
|
-
}
|
|
284
|
-
|
|
285
279
|
function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult {
|
|
286
280
|
return { kind: "unavailable", failure, legacy: { kind: "null" } };
|
|
287
281
|
}
|
|
@@ -306,13 +300,16 @@ function antigravityUnavailableFailure(
|
|
|
306
300
|
return fallbackFailure;
|
|
307
301
|
}
|
|
308
302
|
|
|
309
|
-
export async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise<AntigravityQuotaProbeResult> {
|
|
303
|
+
export async function probeAntigravityUsageQuota(accessToken: string, projectId: string, signal?: AbortSignal): Promise<AntigravityQuotaProbeResult> {
|
|
304
|
+
// An optional caller signal (account import) cancels every request of this probe, including the
|
|
305
|
+
// 403 retry and the models fallback, alongside the per-request deadline.
|
|
306
|
+
const requestSignal = () => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
310
307
|
const fetchQuota = (url: string, userAgent = antigravityUserAgent()) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
|
|
311
308
|
headers: {
|
|
312
309
|
Accept: "application/json", "Content-Type": "application/json",
|
|
313
310
|
"User-Agent": userAgent, Authorization: `Bearer ${accessToken}`,
|
|
314
311
|
},
|
|
315
|
-
body: JSON.stringify({ project: projectId }), signal:
|
|
312
|
+
body: JSON.stringify({ project: projectId }), signal: requestSignal(),
|
|
316
313
|
}, antigravityOutboundDependencies);
|
|
317
314
|
let summaryFailure: QuotaFailureCode | undefined;
|
|
318
315
|
try {
|
|
@@ -332,13 +329,14 @@ export async function probeAntigravityUsageQuota(accessToken: string, projectId:
|
|
|
332
329
|
// Existing behavior: summary transport/parse failure may recover through the models probe.
|
|
333
330
|
summaryFailure = quotaTransportFailure(error);
|
|
334
331
|
}
|
|
332
|
+
if (signal?.aborted) return unavailableAntigravityQuota(summaryFailure ?? "transport_error");
|
|
335
333
|
try {
|
|
336
334
|
const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL);
|
|
337
335
|
if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) {
|
|
338
336
|
return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, "redirect_blocked"));
|
|
339
337
|
}
|
|
340
338
|
if (!response.ok) {
|
|
341
|
-
return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure,
|
|
339
|
+
return unavailableAntigravityQuota(antigravityUnavailableFailure(summaryFailure, httpQuotaFailure(response.status)));
|
|
342
340
|
}
|
|
343
341
|
const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
|
|
344
342
|
if (!customWindows.length) {
|
|
@@ -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 =>
|
package/src/providers/quota.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listCodexAuthAccountsSnapshot } from "../codex/auth-api";
|
|
2
2
|
import { isAnthropicInstanceId, configuredAnthropicInstance } from "./anthropic-instance";
|
|
3
3
|
import { fetchAnthropicAccountQuota } from "./quota/anthropic-account-quota";
|
|
4
|
+
import { thrownQuotaFailure } from "./quota/failure-codes";
|
|
4
5
|
import { resolveEnvValue } from "../config";
|
|
5
6
|
import { getAccountCredential, getAccountSet } from "../oauth/store";
|
|
6
7
|
import { apiKeyPoolEntryId } from "./api-keys";
|
|
@@ -515,7 +516,10 @@ async function fetchAccountQuota(
|
|
|
515
516
|
}
|
|
516
517
|
return entry;
|
|
517
518
|
} catch (error) {
|
|
518
|
-
|
|
519
|
+
// The probe itself reports HTTP and transport failures as results; what throws here is the
|
|
520
|
+
// credential side (missing/paused account, failed refresh, no project). A refresh that hit
|
|
521
|
+
// its deadline is a timeout to retry, not an account the operator must re-sign.
|
|
522
|
+
if (provider === "google-antigravity") quotaFailure = thrownQuotaFailure(error) === "timeout" ? "timeout" : "account_unavailable";
|
|
519
523
|
return unavailable();
|
|
520
524
|
}
|
|
521
525
|
})().finally(() => {
|
|
@@ -1020,7 +1020,12 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
|
|
|
1020
1020
|
},
|
|
1021
1021
|
},
|
|
1022
1022
|
// FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
1023
|
-
{
|
|
1023
|
+
{
|
|
1024
|
+
id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key",
|
|
1025
|
+
dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest", promptCacheKey: true,
|
|
1026
|
+
// Native text-only input enrolls this model in the existing vision sidecar.
|
|
1027
|
+
modelInputModalities: { "zai-glm-5-3": ["text"] },
|
|
1028
|
+
},
|
|
1024
1029
|
{
|
|
1025
1030
|
id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key",
|
|
1026
1031
|
dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS,
|
|
@@ -134,7 +134,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
134
134
|
const repairLegacyMimoFreeAuth = providerName === "mimo-free"
|
|
135
135
|
&& staticModelCatalog
|
|
136
136
|
&& (provider.authMode === undefined || provider.authMode === "local");
|
|
137
|
-
const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider
|
|
137
|
+
const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider);
|
|
138
138
|
const canonicalAuthMode = explicitKeyOverride
|
|
139
139
|
? "key"
|
|
140
140
|
: repairLegacyMimoFreeAuth
|
|
@@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
3
3
|
import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress";
|
|
4
4
|
import { configuredOutboundFetch } from "../lib/proxy-env";
|
|
5
5
|
import { resolveGithubCopilotTransport } from "./github-copilot-transport";
|
|
6
|
+
import { resolveMistralTransport } from "./mistral-transport";
|
|
6
7
|
|
|
7
8
|
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
8
9
|
|
|
@@ -121,7 +122,7 @@ export function resolveProviderTransport(
|
|
|
121
122
|
if (providerName === "github-copilot") {
|
|
122
123
|
return resolveGithubCopilotTransport(provider, apiBaseUrl);
|
|
123
124
|
}
|
|
124
|
-
if (providerName !== "xai") return provider;
|
|
125
|
+
if (providerName !== "xai") return resolveMistralTransport(provider);
|
|
125
126
|
|
|
126
127
|
const cacheKey = promptCacheKey?.trim();
|
|
127
128
|
const affinity = cacheKey ? deriveXaiConvId(cacheKey) : undefined;
|