@bitkyc08/opencodex 2.10.0 → 2.10.1
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/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
package/src/codex/routing.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { saveConfigPreservingClaudeCode } from "../config";
|
|
|
3
3
|
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
|
|
4
4
|
import { codexAccountLogLabel } from "./account-label";
|
|
5
5
|
import { isCodexAccountPaused } from "./account-pause";
|
|
6
|
-
import { isCodexAccountUsable } from "./account-usability";
|
|
6
|
+
import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
|
|
7
7
|
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
8
|
import {
|
|
9
9
|
POOL_KEY_CODEX,
|
|
@@ -22,6 +22,7 @@ import type { OcxConfig } from "../types";
|
|
|
22
22
|
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
23
23
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
24
24
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
25
|
+
import { recordUpstreamHostFailure } from "./upstream-host-health";
|
|
25
26
|
|
|
26
27
|
type ThreadAffinityEntry = {
|
|
27
28
|
accountId: string;
|
|
@@ -122,8 +123,8 @@ const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHeal
|
|
|
122
123
|
let lastReconciledGeneration = 0;
|
|
123
124
|
let liveHealthAccountIds = new Set<string>();
|
|
124
125
|
|
|
125
|
-
export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
|
|
126
|
-
export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
|
|
126
|
+
export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
|
|
127
|
+
export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "neutral" | "unknown";
|
|
127
128
|
export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
128
129
|
/**
|
|
129
130
|
* Native Codex quota groups known to be independent upstream. Keep the mapping
|
|
@@ -133,6 +134,20 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
|
133
134
|
*/
|
|
134
135
|
export type CodexQuotaScope = "shared" | "spark";
|
|
135
136
|
|
|
137
|
+
export type CodexQuotaRecoveryProbeClaim = {
|
|
138
|
+
accountId: string;
|
|
139
|
+
scope?: CodexQuotaScope;
|
|
140
|
+
leaseId: string;
|
|
141
|
+
cooldownGeneration: number;
|
|
142
|
+
credentialGeneration: number;
|
|
143
|
+
/** Claim-time `replacedAt`; unchanged after a probe-owned refresh, stamped on external replacement. */
|
|
144
|
+
credentialReplacedAt?: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type CodexQuotaRecoveryProbeProof = {
|
|
148
|
+
credentialGeneration?: number;
|
|
149
|
+
};
|
|
150
|
+
|
|
136
151
|
/**
|
|
137
152
|
* Requests without a resolved native model retain the historic one-account-per-
|
|
138
153
|
* thread behavior. Requests with a known quota scope get an independent
|
|
@@ -173,10 +188,20 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
173
188
|
retryAfter?: string | null;
|
|
174
189
|
resetAt?: unknown | unknown[];
|
|
175
190
|
now?: number;
|
|
191
|
+
/** (provider, host) ledger key for account-neutral reachability failures (#914). */
|
|
192
|
+
hostKey?: string;
|
|
193
|
+
/** Stable transport code recorded alongside a neutral host failure. */
|
|
194
|
+
lastFailureCode?: string;
|
|
176
195
|
/** Native model selected for this request; used only for confirmed scoped quotas. */
|
|
177
196
|
modelId?: string;
|
|
178
197
|
/** When set, clears affinity for this thread immediately on transient failure. */
|
|
179
198
|
threadId?: string | null;
|
|
199
|
+
/**
|
|
200
|
+
* Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
|
|
201
|
+
* request. Credential failures still sweep stale affinities because reauthentication is
|
|
202
|
+
* account-wide.
|
|
203
|
+
*/
|
|
204
|
+
fixedAccount?: boolean;
|
|
180
205
|
/**
|
|
181
206
|
* Probe lease held by this request, when it was admitted through an active
|
|
182
207
|
* quota cooldown. Only the outcome carrying the current lease may clear the
|
|
@@ -195,8 +220,14 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
195
220
|
writerGeneration?: number;
|
|
196
221
|
};
|
|
197
222
|
|
|
198
|
-
function hasConfiguredPoolAccount(
|
|
199
|
-
|
|
223
|
+
function hasConfiguredPoolAccount(
|
|
224
|
+
config: OcxConfig,
|
|
225
|
+
accountId: string,
|
|
226
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
227
|
+
): boolean {
|
|
228
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
229
|
+
return isCodexAccountUsable(config, accountId, selectionOptions);
|
|
230
|
+
}
|
|
200
231
|
return (config.codexAccounts ?? [])
|
|
201
232
|
.some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
|
|
202
233
|
}
|
|
@@ -295,9 +326,15 @@ export function computeCodexUsageScore(quota: {
|
|
|
295
326
|
}
|
|
296
327
|
|
|
297
328
|
export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
|
|
329
|
+
if (outcome === "connect_neutral") return "neutral";
|
|
298
330
|
if (outcome === "connect_error" || outcome === "timeout") return "transient";
|
|
299
331
|
if (!Number.isFinite(outcome)) return "unknown";
|
|
300
332
|
if (outcome >= 200 && outcome < 300) return "success";
|
|
333
|
+
// Explicit 3xx policy (#914): a redirect response is relayed as-is and is
|
|
334
|
+
// never account or host health evidence — it proves the host is reachable
|
|
335
|
+
// and says nothing about the credential. Relayed as the neutral class so a
|
|
336
|
+
// stray 3xx cannot increment an account's transient streak.
|
|
337
|
+
if (outcome >= 300 && outcome < 400) return "neutral";
|
|
301
338
|
if (outcome === 401 || outcome === 403) return "credential";
|
|
302
339
|
// 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
|
|
303
340
|
// (same-request alternate retry records this outcome for the depleted account).
|
|
@@ -405,6 +442,135 @@ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now:
|
|
|
405
442
|
return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
|
|
406
443
|
}
|
|
407
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Claim due reset-derived cooldown probes without consulting account selection.
|
|
447
|
+
* Pool credentials only: the main account has no quota-refresh single-flight.
|
|
448
|
+
*/
|
|
449
|
+
export function claimDueCodexQuotaRecoveryProbes(
|
|
450
|
+
config: OcxConfig,
|
|
451
|
+
limit: number,
|
|
452
|
+
now = Date.now(),
|
|
453
|
+
): CodexQuotaRecoveryProbeClaim[] {
|
|
454
|
+
const boundedLimit = Math.max(0, Math.floor(limit));
|
|
455
|
+
if (boundedLimit === 0) return [];
|
|
456
|
+
const candidates: Array<{
|
|
457
|
+
accountId: string;
|
|
458
|
+
scope?: CodexQuotaScope;
|
|
459
|
+
health: CodexUpstreamHealth;
|
|
460
|
+
credentialGeneration: number;
|
|
461
|
+
credentialReplacedAt?: number;
|
|
462
|
+
order: number;
|
|
463
|
+
}> = [];
|
|
464
|
+
for (const [order, account] of (config.codexAccounts ?? []).entries()) {
|
|
465
|
+
if (!isSelectableCodexPoolAccount(account)
|
|
466
|
+
|| isCodexAccountPaused(config, account.id)
|
|
467
|
+
|| isAccountNeedsReauth(account.id)) continue;
|
|
468
|
+
const record = readCodexAccountRecord(account.id);
|
|
469
|
+
if (!record?.credential || record.deletedAt != null) continue;
|
|
470
|
+
const due = [
|
|
471
|
+
{ scope: undefined, health: upstreamHealth.get(account.id) },
|
|
472
|
+
...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
|
|
473
|
+
].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
|
|
474
|
+
// `spark` is deliberately never claimed. `GET /backend-api/wham/usage` takes no scope
|
|
475
|
+
// parameter and returns generic weekly/monthly windows, so its result can never prove a
|
|
476
|
+
// spark recovery — a claim here would spend an upstream call to settle `false` every
|
|
477
|
+
// time, and (with one claim per account per pass) delay the shared scope that CAN recover.
|
|
478
|
+
entry.scope !== "spark"
|
|
479
|
+
&& entry.health?.cooldownSource === "reset-derived"
|
|
480
|
+
&& canAcquireQuotaProbeLease(entry.health, now))
|
|
481
|
+
.sort((a, b) =>
|
|
482
|
+
(a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
|
|
483
|
+
- (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0));
|
|
484
|
+
const candidate = due[0];
|
|
485
|
+
if (candidate) candidates.push({
|
|
486
|
+
accountId: account.id,
|
|
487
|
+
...(candidate.scope ? { scope: candidate.scope } : {}),
|
|
488
|
+
health: candidate.health,
|
|
489
|
+
credentialGeneration: record.generation,
|
|
490
|
+
...(record.replacedAt !== undefined ? { credentialReplacedAt: record.replacedAt } : {}),
|
|
491
|
+
order,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
candidates.sort((a, b) => {
|
|
495
|
+
const age = (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
|
|
496
|
+
- (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0);
|
|
497
|
+
return age || a.order - b.order;
|
|
498
|
+
});
|
|
499
|
+
return candidates.slice(0, boundedLimit).map(candidate => {
|
|
500
|
+
const leaseId = randomUUID();
|
|
501
|
+
const next = {
|
|
502
|
+
...candidate.health,
|
|
503
|
+
probeLeaseId: leaseId,
|
|
504
|
+
probeLeaseGeneration: candidate.health.cooldownGeneration ?? 0,
|
|
505
|
+
lastProbeAt: now,
|
|
506
|
+
};
|
|
507
|
+
if (candidate.scope) setScopedHealth(candidate.accountId, candidate.scope, next);
|
|
508
|
+
else upstreamHealth.set(candidate.accountId, next);
|
|
509
|
+
return {
|
|
510
|
+
accountId: candidate.accountId,
|
|
511
|
+
...(candidate.scope ? { scope: candidate.scope } : {}),
|
|
512
|
+
leaseId,
|
|
513
|
+
cooldownGeneration: candidate.health.cooldownGeneration ?? 0,
|
|
514
|
+
credentialGeneration: candidate.credentialGeneration,
|
|
515
|
+
...(candidate.credentialReplacedAt !== undefined
|
|
516
|
+
? { credentialReplacedAt: candidate.credentialReplacedAt }
|
|
517
|
+
: {}),
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Settle one background recovery claim without mutating account-wide outcome state. */
|
|
523
|
+
export function settleCodexQuotaRecoveryProbe(
|
|
524
|
+
claim: CodexQuotaRecoveryProbeClaim,
|
|
525
|
+
recovered: boolean,
|
|
526
|
+
proof: CodexQuotaRecoveryProbeProof,
|
|
527
|
+
now = Date.now(),
|
|
528
|
+
): boolean {
|
|
529
|
+
const health = claim.scope
|
|
530
|
+
? scopedHealthFor(claim.accountId, claim.scope)
|
|
531
|
+
: upstreamHealth.get(claim.accountId);
|
|
532
|
+
if (!health || health.probeLeaseId !== claim.leaseId) return false;
|
|
533
|
+
const currentRecord = readCodexAccountRecord(claim.accountId);
|
|
534
|
+
const proofGeneration = proof.credentialGeneration;
|
|
535
|
+
// A probe-owned token refresh (getValidCodexToken) advances the credential generation by
|
|
536
|
+
// exactly one while preserving `replacedAt`; an external credential replacement bumps the
|
|
537
|
+
// generation too but stamps a fresh `replacedAt`. Accept the +1 transition only when the
|
|
538
|
+
// claim-time lineage is intact AND the generation the fresh quota was proven under is live.
|
|
539
|
+
const generationFenced = proofGeneration !== undefined
|
|
540
|
+
&& (proofGeneration === claim.credentialGeneration
|
|
541
|
+
? isCodexAccountGenerationLive(claim.accountId, proofGeneration)
|
|
542
|
+
: proofGeneration === claim.credentialGeneration + 1
|
|
543
|
+
&& currentRecord?.replacedAt === claim.credentialReplacedAt
|
|
544
|
+
&& isCodexAccountGenerationLive(claim.accountId, proofGeneration));
|
|
545
|
+
const fenced = (health.cooldownGeneration ?? 0) === claim.cooldownGeneration
|
|
546
|
+
&& (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration
|
|
547
|
+
&& generationFenced;
|
|
548
|
+
if (!recovered || !fenced) {
|
|
549
|
+
const released = withProbeLeaseReleased(health, now);
|
|
550
|
+
if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released);
|
|
551
|
+
else upstreamHealth.set(claim.accountId, released);
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
if (claim.scope) {
|
|
555
|
+
deleteScopedHealth(claim.accountId, claim.scope);
|
|
556
|
+
} else {
|
|
557
|
+
const {
|
|
558
|
+
cooldownUntil: _until,
|
|
559
|
+
cooldownSince: _since,
|
|
560
|
+
cooldownSource: _source,
|
|
561
|
+
probeLeaseId: _leaseId,
|
|
562
|
+
probeLeaseGeneration: _leaseGeneration,
|
|
563
|
+
...rest
|
|
564
|
+
} = health;
|
|
565
|
+
upstreamHealth.set(claim.accountId, {
|
|
566
|
+
...rest,
|
|
567
|
+
cooldownGeneration: claim.cooldownGeneration + 1,
|
|
568
|
+
lastProbeAt: now,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
|
|
408
574
|
/** Acquire the recovery probe for one confirmed model-specific quota group. */
|
|
409
575
|
export function tryAcquireCodexQuotaScopeProbeLease(
|
|
410
576
|
accountId: string,
|
|
@@ -624,11 +790,12 @@ function isCodexAccountSelectable(
|
|
|
624
790
|
accountId: string,
|
|
625
791
|
now: number,
|
|
626
792
|
quotaScope?: CodexQuotaScope,
|
|
793
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
627
794
|
): boolean {
|
|
628
795
|
return !isCodexAccountPaused(config, accountId)
|
|
629
796
|
&& getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
|
|
630
797
|
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
631
|
-
&& isCodexAccountUsable(config, accountId);
|
|
798
|
+
&& isCodexAccountUsable(config, accountId, selectionOptions);
|
|
632
799
|
}
|
|
633
800
|
|
|
634
801
|
function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
|
|
@@ -736,6 +903,7 @@ function getEligiblePoolAccounts(
|
|
|
736
903
|
excludeId?: string,
|
|
737
904
|
now = Date.now(),
|
|
738
905
|
quotaScope?: CodexQuotaScope,
|
|
906
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
739
907
|
): string[] {
|
|
740
908
|
const ids = (config.codexAccounts ?? [])
|
|
741
909
|
.filter(account => isSelectableCodexPoolAccount(account)
|
|
@@ -744,7 +912,7 @@ function getEligiblePoolAccounts(
|
|
|
744
912
|
&& !isAccountNeedsReauth(account.id))
|
|
745
913
|
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
|
746
914
|
.filter(account => !isCodexAccountSoftAvoided(account.id, now))
|
|
747
|
-
.filter(account => isCodexAccountUsable(config, account.id))
|
|
915
|
+
.filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
|
|
748
916
|
.map(account => account.id);
|
|
749
917
|
// The main Codex account is not stored in config.codexAccounts; include it as a
|
|
750
918
|
// first-class rotation candidate when its read-only token is usable (Option A).
|
|
@@ -754,7 +922,7 @@ function getEligiblePoolAccounts(
|
|
|
754
922
|
&& !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|
|
755
923
|
&& getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
|
|
756
924
|
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
757
|
-
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
|
|
925
|
+
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
|
|
758
926
|
) {
|
|
759
927
|
ids.unshift(MAIN_CODEX_ACCOUNT_ID);
|
|
760
928
|
}
|
|
@@ -765,8 +933,9 @@ function listEligibleCodexAccountIds(
|
|
|
765
933
|
config: OcxConfig,
|
|
766
934
|
now: number,
|
|
767
935
|
quotaScope?: CodexQuotaScope,
|
|
936
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
768
937
|
): string[] {
|
|
769
|
-
return getEligiblePoolAccounts(config, undefined, now, quotaScope);
|
|
938
|
+
return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
|
|
770
939
|
}
|
|
771
940
|
|
|
772
941
|
function stickyLimitForConfig(config: OcxConfig): number {
|
|
@@ -790,8 +959,9 @@ function pickFillFirstCodexAccount(
|
|
|
790
959
|
config: OcxConfig,
|
|
791
960
|
now: number,
|
|
792
961
|
quotaScope?: CodexQuotaScope,
|
|
962
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
793
963
|
): string | null {
|
|
794
|
-
const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
|
|
964
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
|
|
795
965
|
if (eligible.length === 0) return null;
|
|
796
966
|
|
|
797
967
|
const active = getEffectiveActiveCodexAccountId(config);
|
|
@@ -799,7 +969,7 @@ function pickFillFirstCodexAccount(
|
|
|
799
969
|
return active;
|
|
800
970
|
}
|
|
801
971
|
|
|
802
|
-
return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now);
|
|
972
|
+
return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
|
|
803
973
|
}
|
|
804
974
|
|
|
805
975
|
/** Next eligible account in stable order after `afterId` (wrapping). */
|
|
@@ -808,6 +978,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
808
978
|
afterId: string | null,
|
|
809
979
|
eligible = listEligibleCodexAccountIds(config, Date.now()),
|
|
810
980
|
_now = Date.now(),
|
|
981
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
811
982
|
): string | null {
|
|
812
983
|
if (eligible.length === 0) return null;
|
|
813
984
|
const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
|
|
@@ -820,7 +991,7 @@ function pickNextFillFirstCodexAccount(
|
|
|
820
991
|
}
|
|
821
992
|
|
|
822
993
|
const allConfigured = [
|
|
823
|
-
...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID
|
|
994
|
+
...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
|
|
824
995
|
? [MAIN_CODEX_ACCOUNT_ID]
|
|
825
996
|
: []),
|
|
826
997
|
...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
|
|
@@ -866,6 +1037,7 @@ function pickUnboundStrategyAccount(
|
|
|
866
1037
|
now: number,
|
|
867
1038
|
commit: boolean,
|
|
868
1039
|
quotaScope?: CodexQuotaScope,
|
|
1040
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
869
1041
|
): string | null {
|
|
870
1042
|
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
871
1043
|
if (strategy === "quota") return null;
|
|
@@ -873,7 +1045,7 @@ function pickUnboundStrategyAccount(
|
|
|
873
1045
|
|
|
874
1046
|
let picked: string | null = null;
|
|
875
1047
|
if (strategy === "round-robin") {
|
|
876
|
-
const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
|
|
1048
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
|
|
877
1049
|
const limit = stickyLimitForConfig(config);
|
|
878
1050
|
if (!commit) {
|
|
879
1051
|
return peekRoundRobinAccount(poolKey, eligible, limit);
|
|
@@ -887,7 +1059,7 @@ function pickUnboundStrategyAccount(
|
|
|
887
1059
|
}
|
|
888
1060
|
|
|
889
1061
|
if (strategy === "fill-first") {
|
|
890
|
-
picked = pickFillFirstCodexAccount(config, now, quotaScope);
|
|
1062
|
+
picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
|
|
891
1063
|
if (!picked) return null;
|
|
892
1064
|
if (commit) {
|
|
893
1065
|
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
|
|
@@ -911,10 +1083,11 @@ function pickLowerUsageAccount(
|
|
|
911
1083
|
activeUsage: number,
|
|
912
1084
|
now: number,
|
|
913
1085
|
quotaScope?: CodexQuotaScope,
|
|
1086
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
914
1087
|
): string {
|
|
915
1088
|
let best = active;
|
|
916
1089
|
let bestUsage = activeUsage;
|
|
917
|
-
for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) {
|
|
1090
|
+
for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) {
|
|
918
1091
|
const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
|
|
919
1092
|
if (usage < bestUsage) {
|
|
920
1093
|
best = id;
|
|
@@ -929,10 +1102,11 @@ export function pickLowestUsageCodexAccount(
|
|
|
929
1102
|
excludeId?: string,
|
|
930
1103
|
now = Date.now(),
|
|
931
1104
|
quotaScope?: CodexQuotaScope,
|
|
1105
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
932
1106
|
): string | null {
|
|
933
1107
|
let best: string | null = null;
|
|
934
1108
|
let bestUsage = Number.POSITIVE_INFINITY;
|
|
935
|
-
for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) {
|
|
1109
|
+
for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions)) {
|
|
936
1110
|
const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
|
|
937
1111
|
if (usage < bestUsage) {
|
|
938
1112
|
best = id;
|
|
@@ -952,17 +1126,18 @@ export function pickAlternateCodexAccount(
|
|
|
952
1126
|
excludeId: string,
|
|
953
1127
|
now = Date.now(),
|
|
954
1128
|
quotaScope?: CodexQuotaScope,
|
|
1129
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
955
1130
|
): string | null {
|
|
956
1131
|
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
957
1132
|
if (strategy === "round-robin") {
|
|
958
|
-
const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
|
|
1133
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions).filter(id => id !== excludeId);
|
|
959
1134
|
return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
|
|
960
1135
|
}
|
|
961
1136
|
if (strategy === "fill-first") {
|
|
962
|
-
const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
|
|
963
|
-
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now);
|
|
1137
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions).filter(id => id !== excludeId);
|
|
1138
|
+
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
|
|
964
1139
|
}
|
|
965
|
-
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope);
|
|
1140
|
+
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
|
|
966
1141
|
}
|
|
967
1142
|
|
|
968
1143
|
/** Effective active: automatic runtime cursor, else operator/persisted selection. */
|
|
@@ -1027,6 +1202,7 @@ function applyQuotaAutoSwitch(
|
|
|
1027
1202
|
active: string,
|
|
1028
1203
|
now: number,
|
|
1029
1204
|
quotaScope?: CodexQuotaScope,
|
|
1205
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1030
1206
|
): string {
|
|
1031
1207
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1032
1208
|
if (threshold <= 0) return active;
|
|
@@ -1036,7 +1212,7 @@ function applyQuotaAutoSwitch(
|
|
|
1036
1212
|
// threshold. Wait for quota priming instead of rotating among guesses.
|
|
1037
1213
|
if (isUnknownUsage(activeUsage)) return active;
|
|
1038
1214
|
if (activeUsage < threshold) return active;
|
|
1039
|
-
const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope);
|
|
1215
|
+
const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
|
|
1040
1216
|
if (best !== active) {
|
|
1041
1217
|
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
|
|
1042
1218
|
return best;
|
|
@@ -1053,9 +1229,14 @@ function shouldFailover(config: OcxConfig, accountId: string, now: number): bool
|
|
|
1053
1229
|
return !!health && health.consecutiveFailures >= threshold;
|
|
1054
1230
|
}
|
|
1055
1231
|
|
|
1056
|
-
function applyFailureFailover(
|
|
1232
|
+
function applyFailureFailover(
|
|
1233
|
+
config: OcxConfig,
|
|
1234
|
+
active: string,
|
|
1235
|
+
now: number,
|
|
1236
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1237
|
+
): string {
|
|
1057
1238
|
if (!shouldFailover(config, active, now)) return active;
|
|
1058
|
-
const best = pickAlternateCodexAccount(config, active, now);
|
|
1239
|
+
const best = pickAlternateCodexAccount(config, active, now, undefined, selectionOptions);
|
|
1059
1240
|
if (best) {
|
|
1060
1241
|
promoteActiveCodexAccount(config, best);
|
|
1061
1242
|
return best;
|
|
@@ -1086,13 +1267,14 @@ export function previewCodexAccountForRequest(
|
|
|
1086
1267
|
config: OcxConfig,
|
|
1087
1268
|
now = Date.now(),
|
|
1088
1269
|
quotaScope?: CodexQuotaScope,
|
|
1270
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1089
1271
|
): string | null {
|
|
1090
1272
|
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1091
1273
|
if (threadId && entry) {
|
|
1092
1274
|
if (
|
|
1093
1275
|
!isThreadAffinityExpired(entry, now)
|
|
1094
1276
|
&& isThreadAffinityGenerationLive(entry)
|
|
1095
|
-
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
|
|
1277
|
+
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
|
|
1096
1278
|
&& !shouldFailover(config, entry.accountId, now)
|
|
1097
1279
|
) {
|
|
1098
1280
|
// Quota strategy only: non-quota strategies keep affinity for ongoing threads
|
|
@@ -1106,7 +1288,14 @@ export function previewCodexAccountForRequest(
|
|
|
1106
1288
|
getPoolAccountPlan(config, entry.accountId),
|
|
1107
1289
|
);
|
|
1108
1290
|
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1109
|
-
const best = pickLowerUsageAccount(
|
|
1291
|
+
const best = pickLowerUsageAccount(
|
|
1292
|
+
config,
|
|
1293
|
+
entry.accountId,
|
|
1294
|
+
usage,
|
|
1295
|
+
now,
|
|
1296
|
+
quotaScope,
|
|
1297
|
+
selectionOptions,
|
|
1298
|
+
);
|
|
1110
1299
|
if (best !== entry.accountId) return best;
|
|
1111
1300
|
}
|
|
1112
1301
|
}
|
|
@@ -1116,17 +1305,27 @@ export function previewCodexAccountForRequest(
|
|
|
1116
1305
|
// Stale/unusable affinity is ignored for preview (no map mutation).
|
|
1117
1306
|
}
|
|
1118
1307
|
|
|
1119
|
-
const strategyPick = pickUnboundStrategyAccount(
|
|
1308
|
+
const strategyPick = pickUnboundStrategyAccount(
|
|
1309
|
+
config,
|
|
1310
|
+
threadId,
|
|
1311
|
+
now,
|
|
1312
|
+
false,
|
|
1313
|
+
quotaScope,
|
|
1314
|
+
selectionOptions,
|
|
1315
|
+
);
|
|
1120
1316
|
if (strategyPick) return strategyPick;
|
|
1121
1317
|
|
|
1122
1318
|
let active = getEffectiveActiveCodexAccountId(config) ?? null;
|
|
1123
1319
|
if (!active) {
|
|
1124
|
-
return pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
|
|
1320
|
+
return pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
|
|
1125
1321
|
}
|
|
1126
|
-
if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
|
|
1127
|
-
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
|
|
1322
|
+
if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
|
|
1323
|
+
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
1128
1324
|
if (fallback) active = fallback;
|
|
1129
|
-
else if (
|
|
1325
|
+
else if (
|
|
1326
|
+
hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1327
|
+
&& !isCodexAccountPaused(config, active)
|
|
1328
|
+
) return active;
|
|
1130
1329
|
else return null;
|
|
1131
1330
|
}
|
|
1132
1331
|
|
|
@@ -1134,19 +1333,19 @@ export function previewCodexAccountForRequest(
|
|
|
1134
1333
|
if (threshold > 0) {
|
|
1135
1334
|
const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
|
|
1136
1335
|
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1137
|
-
active = pickLowerUsageAccount(config, active, usage, now, quotaScope);
|
|
1336
|
+
active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
|
|
1138
1337
|
}
|
|
1139
1338
|
}
|
|
1140
1339
|
if (shouldFailover(config, active, now)) {
|
|
1141
|
-
const best = pickLowestUsageCodexAccount(config, active, now, quotaScope);
|
|
1340
|
+
const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
1142
1341
|
if (best) active = best;
|
|
1143
1342
|
}
|
|
1144
|
-
if (!isCodexAccountUsable(config, active)) {
|
|
1145
|
-
return hasConfiguredPoolAccount(config, active) ? active : null;
|
|
1343
|
+
if (!isCodexAccountUsable(config, active, selectionOptions)) {
|
|
1344
|
+
return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
|
|
1146
1345
|
}
|
|
1147
1346
|
if (isCodexAccountPaused(config, active)) return null;
|
|
1148
1347
|
if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
|
|
1149
|
-
return hasConfiguredPoolAccount(config, active) ? active : null;
|
|
1348
|
+
return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
|
|
1150
1349
|
}
|
|
1151
1350
|
return active;
|
|
1152
1351
|
}
|
|
@@ -1156,6 +1355,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1156
1355
|
config: OcxConfig,
|
|
1157
1356
|
now = Date.now(),
|
|
1158
1357
|
quotaScope?: CodexQuotaScope,
|
|
1358
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1159
1359
|
): CodexThreadResolution {
|
|
1160
1360
|
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1161
1361
|
if (threadId && entry) {
|
|
@@ -1165,7 +1365,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1165
1365
|
}
|
|
1166
1366
|
if (
|
|
1167
1367
|
isThreadAffinityGenerationLive(entry)
|
|
1168
|
-
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
|
|
1368
|
+
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
|
|
1169
1369
|
// Affined threads must leave a failing account once the streak trips failover
|
|
1170
1370
|
// (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
|
|
1171
1371
|
&& !shouldFailover(config, entry.accountId, now)
|
|
@@ -1192,7 +1392,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1192
1392
|
if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
|
|
1193
1393
|
entry.lastReevalAt = now;
|
|
1194
1394
|
if (overThreshold) {
|
|
1195
|
-
const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
|
|
1395
|
+
const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions);
|
|
1196
1396
|
if (best !== entry.accountId) {
|
|
1197
1397
|
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
|
|
1198
1398
|
bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
|
|
@@ -1206,35 +1406,42 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
1206
1406
|
deleteThreadAffinity(threadId, quotaScope);
|
|
1207
1407
|
}
|
|
1208
1408
|
|
|
1209
|
-
const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope);
|
|
1409
|
+
const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope, selectionOptions);
|
|
1210
1410
|
if (strategyPick) return { status: "selected", accountId: strategyPick };
|
|
1211
1411
|
|
|
1212
1412
|
let active = getEffectiveActiveCodexAccountId(config);
|
|
1213
1413
|
if (!active) {
|
|
1214
|
-
const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
|
|
1414
|
+
const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
|
|
1215
1415
|
if (!selected) return { status: "none" };
|
|
1216
1416
|
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
|
|
1217
1417
|
active = selected;
|
|
1218
1418
|
}
|
|
1219
|
-
if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
|
|
1220
|
-
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
|
|
1419
|
+
if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
|
|
1420
|
+
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
|
|
1221
1421
|
if (fallback) {
|
|
1222
1422
|
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
|
|
1223
1423
|
active = fallback;
|
|
1224
|
-
} else if (
|
|
1424
|
+
} else if (
|
|
1425
|
+
hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1426
|
+
&& !isCodexAccountPaused(config, active)
|
|
1427
|
+
) {
|
|
1225
1428
|
return { status: "selected", accountId: active };
|
|
1226
1429
|
} else {
|
|
1227
1430
|
return { status: "none" };
|
|
1228
1431
|
}
|
|
1229
1432
|
}
|
|
1230
|
-
active = applyQuotaAutoSwitch(config, active, now, quotaScope);
|
|
1231
|
-
active = applyFailureFailover(config, active, now);
|
|
1232
|
-
if (!isCodexAccountUsable(config, active)) {
|
|
1233
|
-
return hasConfiguredPoolAccount(config, active
|
|
1433
|
+
active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
|
|
1434
|
+
active = applyFailureFailover(config, active, now, selectionOptions);
|
|
1435
|
+
if (!isCodexAccountUsable(config, active, selectionOptions)) {
|
|
1436
|
+
return hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1437
|
+
? { status: "selected", accountId: active }
|
|
1438
|
+
: { status: "none" };
|
|
1234
1439
|
}
|
|
1235
1440
|
if (isCodexAccountPaused(config, active)) return { status: "none" };
|
|
1236
1441
|
if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
|
|
1237
|
-
return hasConfiguredPoolAccount(config, active
|
|
1442
|
+
return hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
1443
|
+
? { status: "selected", accountId: active }
|
|
1444
|
+
: { status: "none" };
|
|
1238
1445
|
}
|
|
1239
1446
|
if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
|
|
1240
1447
|
return { status: "selected", accountId: active };
|
|
@@ -1246,6 +1453,13 @@ export function recordCodexUpstreamOutcome(
|
|
|
1246
1453
|
outcome: CodexUpstreamOutcome,
|
|
1247
1454
|
meta: CodexUpstreamOutcomeMeta = {},
|
|
1248
1455
|
): void {
|
|
1456
|
+
// Host-level evidence is account-independent (#914): a pre-connection
|
|
1457
|
+
// reachability failure is recorded in the (provider, host) ledger even when
|
|
1458
|
+
// there is no account to attribute, or the account's writer generation is
|
|
1459
|
+
// stale — the early returns below must not gate it.
|
|
1460
|
+
if (outcome === "connect_neutral" && meta.hostKey) {
|
|
1461
|
+
recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() });
|
|
1462
|
+
}
|
|
1249
1463
|
if (!accountId) return;
|
|
1250
1464
|
const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
|
|
1251
1465
|
if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
|
|
@@ -1310,6 +1524,25 @@ export function recordCodexUpstreamOutcome(
|
|
|
1310
1524
|
return;
|
|
1311
1525
|
}
|
|
1312
1526
|
|
|
1527
|
+
if (outcomeClass === "neutral") {
|
|
1528
|
+
// A proven pre-connection reachability failure (DNS / TCP refusal) or a
|
|
1529
|
+
// relayed 3xx is host-level, not account evidence: rotation cannot repair
|
|
1530
|
+
// it and must not happen (#914). Conclude any owned probe lease, record the
|
|
1531
|
+
// failure under the (provider, host) ledger when one is named, and leave
|
|
1532
|
+
// account health, thread affinity, and the active account untouched.
|
|
1533
|
+
const current = upstreamHealth.get(accountId);
|
|
1534
|
+
const scopedProbe = meta.probeQuotaScope
|
|
1535
|
+
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
1536
|
+
: undefined;
|
|
1537
|
+
if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
|
|
1538
|
+
setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
|
|
1539
|
+
}
|
|
1540
|
+
if (ownsProbeLease(current, meta)) {
|
|
1541
|
+
upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
|
|
1542
|
+
}
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1313
1546
|
const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
|
|
1314
1547
|
if (outcomeClass === "credential") {
|
|
1315
1548
|
// 401/403 quarantines the account for reauth. That supersedes quota state
|
|
@@ -1354,7 +1587,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1354
1587
|
// The shared native scope is the existing account-wide native behavior:
|
|
1355
1588
|
// threads must leave it and new requests should prefer an eligible account.
|
|
1356
1589
|
// Spark remains isolated so a same-account Terra/Luna combo fallback can run.
|
|
1357
|
-
if (quotaScope === "shared") {
|
|
1590
|
+
if (quotaScope === "shared" && !meta.fixedAccount) {
|
|
1358
1591
|
clearThreadAccountMapForAccount(accountId);
|
|
1359
1592
|
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
|
|
1360
1593
|
if (getEffectiveActiveCodexAccountId(config) === accountId) {
|
|
@@ -1399,17 +1632,19 @@ export function recordCodexUpstreamOutcome(
|
|
|
1399
1632
|
...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
|
|
1400
1633
|
}),
|
|
1401
1634
|
});
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1635
|
+
if (!meta.fixedAccount) {
|
|
1636
|
+
clearThreadAccountMapForAccount(accountId);
|
|
1637
|
+
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
|
|
1638
|
+
const effectiveActive = getEffectiveActiveCodexAccountId(config);
|
|
1639
|
+
if (effectiveActive === accountId) {
|
|
1640
|
+
// Same-request 429 retry already picked via excludeAccountId — reuse it so
|
|
1641
|
+
// round-robin does not advance the ring a second time.
|
|
1642
|
+
const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
|
|
1643
|
+
? meta.promoteAccountId
|
|
1644
|
+
: null;
|
|
1645
|
+
const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
|
|
1646
|
+
if (fallback) promoteActiveCodexAccount(config, fallback);
|
|
1647
|
+
}
|
|
1413
1648
|
}
|
|
1414
1649
|
return;
|
|
1415
1650
|
}
|
|
@@ -1454,15 +1689,17 @@ export function recordCodexUpstreamOutcome(
|
|
|
1454
1689
|
// thread is still pinned to the FAILING account — a late failure from account A
|
|
1455
1690
|
// must not delete a newer healthy binding to account B (race: T→A, A fails,
|
|
1456
1691
|
// T→B, late A failure must not delete B's mapping).
|
|
1457
|
-
if (failoverReady && meta.threadId) {
|
|
1692
|
+
if (!meta.fixedAccount && failoverReady && meta.threadId) {
|
|
1458
1693
|
deleteThreadAffinitiesForAccount(meta.threadId, accountId);
|
|
1459
1694
|
}
|
|
1460
1695
|
// Once the account is past the failover streak, clear every thread still pinned
|
|
1461
1696
|
// to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
|
|
1462
|
-
if (shouldFailover(config, accountId, now)) {
|
|
1697
|
+
if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
|
|
1463
1698
|
clearThreadAccountMapForAccount(accountId);
|
|
1464
1699
|
}
|
|
1465
|
-
if (getEffectiveActiveCodexAccountId(config) === accountId)
|
|
1700
|
+
if (!meta.fixedAccount && getEffectiveActiveCodexAccountId(config) === accountId) {
|
|
1701
|
+
applyFailureFailover(config, accountId, now);
|
|
1702
|
+
}
|
|
1466
1703
|
}
|
|
1467
1704
|
|
|
1468
1705
|
export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {
|