@iislee/opencodex 2.11.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/LICENSE +21 -0
- package/README.md +225 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +451 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-DTpMHS4F.js +67 -0
- package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/claude.svg +1 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/copilot.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/cursor.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/gemini.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/kiro.svg +14 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +1 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +102 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic.ts +1003 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +72 -0
- package/src/adapters/client-fingerprint.ts +59 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/cursor-errors.ts +165 -0
- package/src/adapters/cursor/discovery.ts +276 -0
- package/src/adapters/cursor/effort-map.ts +127 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +211 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +153 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1214 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +329 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +548 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +576 -0
- package/src/adapters/cursor/protobuf-events.ts +563 -0
- package/src/adapters/cursor/protobuf-request.ts +714 -0
- package/src/adapters/cursor/request-builder.ts +255 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +505 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +52 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +303 -0
- package/src/adapters/google-antigravity-wire.ts +108 -0
- package/src/adapters/google-errors.ts +85 -0
- package/src/adapters/google-http.ts +100 -0
- package/src/adapters/google-tool-schema.ts +173 -0
- package/src/adapters/google-truncation.ts +13 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +758 -0
- package/src/adapters/identity.ts +44 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +197 -0
- package/src/adapters/kiro-events.ts +179 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +96 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +215 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1898 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1005 -0
- package/src/adapters/openai-responses.ts +1137 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/bridge.ts +1619 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +765 -0
- package/src/claude/agents-inject.ts +243 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +381 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +509 -0
- package/src/claude/model-info.ts +151 -0
- package/src/claude/outbound.ts +872 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +268 -0
- package/src/cli/account-auth.ts +223 -0
- package/src/cli/account-extended.ts +350 -0
- package/src/cli/account.ts +275 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +184 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-desktop.ts +188 -0
- package/src/cli/claude.ts +286 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/combo.ts +119 -0
- package/src/cli/config-command.ts +145 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/doctor.ts +930 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +354 -0
- package/src/cli/index.ts +1113 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +142 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +20 -0
- package/src/cli/models-runtime.ts +212 -0
- package/src/cli/models.ts +336 -0
- package/src/cli/observe.ts +117 -0
- package/src/cli/opencode.ts +586 -0
- package/src/cli/pi.ts +188 -0
- package/src/cli/provider-runtime.ts +162 -0
- package/src/cli/provider.ts +463 -0
- package/src/cli/runtime-api.ts +325 -0
- package/src/cli/star-prompt.ts +155 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/sync-cloud.ts +283 -0
- package/src/cli/system-command.ts +112 -0
- package/src/cli/tray-proxy.ts +52 -0
- package/src/cli/v2.ts +173 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +377 -0
- package/src/clients/effective-status.ts +385 -0
- package/src/clients/probes/agy.ts +55 -0
- package/src/clients/probes/cc-switch.ts +110 -0
- package/src/clients/probes/claude.ts +90 -0
- package/src/clients/probes/codex.ts +125 -0
- package/src/clients/probes/grok.ts +29 -0
- package/src/clients/probes/opencode.ts +109 -0
- package/src/clients/probes/paseo.ts +55 -0
- package/src/clients/probes/pi.ts +55 -0
- package/src/cloud/onedrive-auth.ts +666 -0
- package/src/cloud/onedrive-graph.ts +108 -0
- package/src/cloud/settings.ts +75 -0
- package/src/cloud/sync.ts +212 -0
- package/src/cloud/types.ts +56 -0
- package/src/cloud/vault.ts +89 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +55 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +20 -0
- package/src/codex/app-server-processes.ts +756 -0
- package/src/codex/auth-api.ts +1540 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +352 -0
- package/src/codex/autostart-health.ts +149 -0
- package/src/codex/catalog/aggregation.ts +378 -0
- package/src/codex/catalog/bundled.ts +251 -0
- package/src/codex/catalog/effort.ts +355 -0
- package/src/codex/catalog/metadata.ts +180 -0
- package/src/codex/catalog/parsing.ts +456 -0
- package/src/codex/catalog/provider-fetch.ts +902 -0
- package/src/codex/catalog/sync.ts +620 -0
- package/src/codex/catalog.ts +12 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +969 -0
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/codex/history-provider.ts +776 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject.ts +799 -0
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +163 -0
- package/src/codex/main-account-cache.ts +32 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/model-cache.ts +227 -0
- package/src/codex/paths.ts +65 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +225 -0
- package/src/codex/project-config-warnings.ts +411 -0
- package/src/codex/quota.ts +411 -0
- package/src/codex/refresh.ts +53 -0
- package/src/codex/routing.ts +1477 -0
- package/src/codex/runtime.ts +538 -0
- package/src/codex/shim.ts +1189 -0
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +469 -0
- package/src/codex/sync.ts +130 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +41 -0
- package/src/combos/request.ts +62 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +326 -0
- package/src/config.ts +2356 -0
- package/src/generated/jawcode-model-metadata.ts +104 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +545 -0
- package/src/grok/status.ts +121 -0
- package/src/grok/sync.ts +103 -0
- package/src/grok/usage-hook/report.mjs +348 -0
- package/src/grok/usage-hook.ts +278 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +829 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- package/src/lib/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +71 -0
- package/src/lib/bun-stream-caps.ts +126 -0
- package/src/lib/config-ownership.ts +360 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +316 -0
- package/src/lib/errors.ts +364 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +165 -0
- package/src/lib/provider-outbound.ts +170 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +105 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +25 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +323 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/upstream-retry.ts +239 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-elevation.ts +705 -0
- package/src/lib/windows-secret-acl.ts +514 -0
- package/src/lib/winsw.ts +375 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +177 -0
- package/src/oauth/callback-server.ts +294 -0
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/cursor.ts +211 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +399 -0
- package/src/oauth/index.ts +1174 -0
- package/src/oauth/key-providers.ts +108 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +577 -0
- package/src/oauth/local-token-detect.ts +121 -0
- package/src/oauth/log.ts +48 -0
- package/src/oauth/login-cli.ts +163 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +630 -0
- package/src/oauth/token-guardian.ts +303 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/pi/extensions.ts +72 -0
- package/src/pi/home.ts +42 -0
- package/src/pi/index.ts +40 -0
- package/src/pi/models.ts +278 -0
- package/src/pi/packages.ts +219 -0
- package/src/pi/settings.ts +365 -0
- package/src/pi/status.ts +68 -0
- package/src/pi/sync.ts +75 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +205 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/context-cap.ts +65 -0
- package/src/providers/derive.ts +339 -0
- package/src/providers/free-directory.ts +184 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/key-failover.ts +203 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/openai-sidecar.ts +175 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +301 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/quota.ts +1260 -0
- package/src/providers/registry.ts +1600 -0
- package/src/providers/slug-codec.ts +67 -0
- package/src/providers/xai-transport.ts +141 -0
- package/src/reasoning-effort.ts +135 -0
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +656 -0
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +895 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +425 -0
- package/src/server/adapter-resolve.ts +80 -0
- package/src/server/auth-cors.ts +530 -0
- package/src/server/chat-completions.ts +368 -0
- package/src/server/claude-messages.ts +914 -0
- package/src/server/effort-policy.ts +172 -0
- package/src/server/gui-static.ts +123 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +476 -0
- package/src/server/index.ts +1126 -0
- package/src/server/lifecycle.ts +227 -0
- package/src/server/live.ts +598 -0
- package/src/server/management/agent-settings-routes.ts +1169 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/clients-routes.ts +63 -0
- package/src/server/management/cloud-sync-routes.ts +266 -0
- package/src/server/management/combo-routes.ts +220 -0
- package/src/server/management/config-routes.ts +422 -0
- package/src/server/management/context.ts +31 -0
- package/src/server/management/logs-usage-routes.ts +707 -0
- package/src/server/management/model-routes.ts +525 -0
- package/src/server/management/oauth-account-routes.ts +563 -0
- package/src/server/management/provider-routes.ts +556 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +90 -0
- package/src/server/management/system-restart.ts +179 -0
- package/src/server/management/system-routes.ts +117 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +215 -0
- package/src/server/management-auth.ts +267 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +116 -0
- package/src/server/proxy-liveness.ts +201 -0
- package/src/server/relay-eager.ts +313 -0
- package/src/server/relay.ts +1049 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1046 -0
- package/src/server/responses/collaboration.ts +354 -0
- package/src/server/responses/compact.ts +384 -0
- package/src/server/responses/core.ts +2758 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +157 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +224 -0
- package/src/server/responses.ts +9 -0
- package/src/server/search.ts +136 -0
- package/src/server/sse-payload-rewrite.ts +175 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +113 -0
- package/src/server/system-env.ts +413 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +471 -0
- package/src/service.ts +2554 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +290 -0
- package/src/tray/windows.ts +730 -0
- package/src/types.ts +1237 -0
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +407 -0
- package/src/update/job.ts +1520 -0
- package/src/update/notify.ts +257 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +164 -0
- package/src/usage/log.ts +658 -0
- package/src/usage/summary.ts +585 -0
- package/src/usage/totals.ts +14 -0
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/describe.ts +125 -0
- package/src/vision/index.ts +467 -0
- package/src/web-search/anthropic-executor.ts +189 -0
- package/src/web-search/executor.ts +105 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/index.ts +196 -0
- package/src/web-search/loop.ts +664 -0
- package/src/web-search/parse.ts +220 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { saveConfigPreservingClaudeCode } from "../config";
|
|
3
|
+
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
|
|
4
|
+
import { codexAccountLogLabel } from "./account-label";
|
|
5
|
+
import { isCodexAccountPaused } from "./account-pause";
|
|
6
|
+
import { isCodexAccountUsable } from "./account-usability";
|
|
7
|
+
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
8
|
+
import {
|
|
9
|
+
POOL_KEY_CODEX,
|
|
10
|
+
normalizeAccountPoolStickyLimit,
|
|
11
|
+
normalizeAccountPoolStrategy,
|
|
12
|
+
notePoolRotationFailure,
|
|
13
|
+
notePoolRotationSuccess,
|
|
14
|
+
peekRoundRobinAccount,
|
|
15
|
+
pickRoundRobinAccount,
|
|
16
|
+
seedPoolRotationAccount,
|
|
17
|
+
} from "./pool-rotation";
|
|
18
|
+
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
19
|
+
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
|
|
20
|
+
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
21
|
+
import type { OcxConfig } from "../types";
|
|
22
|
+
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
23
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
24
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
25
|
+
|
|
26
|
+
type ThreadAffinityEntry = {
|
|
27
|
+
accountId: string;
|
|
28
|
+
generation: number;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
lastUsedAt: number;
|
|
31
|
+
// Last time the bound account's quota threshold was re-evaluated for this
|
|
32
|
+
// thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
|
|
33
|
+
lastReevalAt: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CodexThreadResolution =
|
|
37
|
+
| { status: "selected"; accountId: string }
|
|
38
|
+
| { status: "none" }
|
|
39
|
+
| { status: "expired"; accountId: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Process-local cursor for automatic RR/fill-first (and quota-429 when not
|
|
43
|
+
* sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
|
|
44
|
+
* rotation as the operator's `activeCodexAccountId`. Manual selection clears it
|
|
45
|
+
* so disk/`config.activeCodexAccountId` remains authoritative.
|
|
46
|
+
*/
|
|
47
|
+
let runtimeActiveCodexAccountId: string | undefined;
|
|
48
|
+
|
|
49
|
+
type CodexUpstreamHealth = {
|
|
50
|
+
consecutiveFailures: number;
|
|
51
|
+
/** Consecutive healthy terminals observed while recovering from escalation level 2+. */
|
|
52
|
+
consecutiveSuccesses?: number;
|
|
53
|
+
lastFailureStatus?: number;
|
|
54
|
+
lastFailureAt?: number;
|
|
55
|
+
/** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
|
|
56
|
+
cooldownUntil?: number;
|
|
57
|
+
/** When the current cooldown was recorded; origin of the probe interval clock. */
|
|
58
|
+
cooldownSince?: number;
|
|
59
|
+
/**
|
|
60
|
+
* What produced the cooldown. An explicit Retry-After is a literal retry
|
|
61
|
+
* directive and is never probed; a quota resetAt only announces a window
|
|
62
|
+
* refresh, so it may be probed early (#433).
|
|
63
|
+
*/
|
|
64
|
+
cooldownSource?: CodexCooldownSource;
|
|
65
|
+
/**
|
|
66
|
+
* Bumped on every cooldown write. A probe lease records the generation it was
|
|
67
|
+
* issued for so a lease cannot clear a cooldown that a later 429 replaced.
|
|
68
|
+
*/
|
|
69
|
+
cooldownGeneration?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Identity of the in-flight probe. A cooled-down account sends no traffic, so
|
|
72
|
+
* no organic 2xx can prove recovery; only the outcome carrying this id may
|
|
73
|
+
* clear the cooldown.
|
|
74
|
+
*/
|
|
75
|
+
probeLeaseId?: string;
|
|
76
|
+
/** Cooldown generation at the moment the lease was granted. */
|
|
77
|
+
probeLeaseGeneration?: number;
|
|
78
|
+
/** Last probe grant or conclusion; paces the probe interval. */
|
|
79
|
+
lastProbeAt?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
|
|
82
|
+
* Blocks pool selection + thread affinity reuse so a sticky session can leave a
|
|
83
|
+
* flaky account without throwing CodexAccountCooldownError (hard-only).
|
|
84
|
+
*/
|
|
85
|
+
softAvoidUntil?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
|
|
89
|
+
const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
90
|
+
/**
|
|
91
|
+
* A weekly/monthly quota `resetAt` announces when the window refreshes; it is not
|
|
92
|
+
* a "come back after this" directive like Retry-After. Plan quota routinely frees
|
|
93
|
+
* up long before the advertised reset, so cap reset-derived cooldowns far below
|
|
94
|
+
* the Retry-After ceiling (#433).
|
|
95
|
+
*/
|
|
96
|
+
const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
|
|
97
|
+
/** Minimum gap between probe leases for one cooled-down account. */
|
|
98
|
+
export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
|
|
99
|
+
export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
|
|
100
|
+
/** How long a transient failure keeps the account out of pool selection. */
|
|
101
|
+
export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
|
|
102
|
+
const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
|
|
103
|
+
CODEX_TRANSIENT_SOFT_AVOID_MS,
|
|
104
|
+
2 * 60_000,
|
|
105
|
+
10 * 60_000,
|
|
106
|
+
30 * 60_000,
|
|
107
|
+
] as const;
|
|
108
|
+
export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
109
|
+
export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
|
|
110
|
+
const MAX_AFFINITY_COMPONENT_BYTES = 512;
|
|
111
|
+
// Min interval between quota threshold re-evaluations for a single bound thread.
|
|
112
|
+
// Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
|
|
113
|
+
export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
|
|
114
|
+
|
|
115
|
+
const upstreamHealth = new Map<string, CodexUpstreamHealth>();
|
|
116
|
+
/**
|
|
117
|
+
* Reset-derived 429s can describe a quota owned by one native model family,
|
|
118
|
+
* rather than the whole ChatGPT account. Keep those advisory cooldowns apart
|
|
119
|
+
* from account-wide Retry-After/default throttles and transient health.
|
|
120
|
+
*/
|
|
121
|
+
const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
|
|
122
|
+
let lastReconciledGeneration = 0;
|
|
123
|
+
let liveHealthAccountIds = new Set<string>();
|
|
124
|
+
|
|
125
|
+
export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
|
|
126
|
+
export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
|
|
127
|
+
export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
128
|
+
/**
|
|
129
|
+
* Native Codex quota groups known to be independent upstream. Keep the mapping
|
|
130
|
+
* deliberately conservative: unlisted models share the normal native group.
|
|
131
|
+
* Add a new explicit group here only when its independent upstream quota is
|
|
132
|
+
* confirmed, so shared limits never receive cross-model bypasses.
|
|
133
|
+
*/
|
|
134
|
+
export type CodexQuotaScope = "shared" | "spark";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Requests without a resolved native model retain the historic one-account-per-
|
|
138
|
+
* thread behavior. Requests with a known quota scope get an independent
|
|
139
|
+
* affinity so a Spark failover cannot displace the same thread's Terra/Luna
|
|
140
|
+
* account (and vice versa).
|
|
141
|
+
*/
|
|
142
|
+
type ThreadAffinityScope = CodexQuotaScope | "legacy";
|
|
143
|
+
const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
|
|
144
|
+
const threadAccountMap = new Map<string, Map<ThreadAffinityScope, ThreadAffinityEntry>>();
|
|
145
|
+
|
|
146
|
+
const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
|
|
147
|
+
"gpt-5.3-codex-spark": "spark",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// A thread can have one legacy binding plus one binding for each known scope.
|
|
151
|
+
// This upper-bound guard avoids an exact map scan until it can be over capacity.
|
|
152
|
+
const MAX_THREAD_AFFINITY_SCOPES = new Set([
|
|
153
|
+
LEGACY_THREAD_AFFINITY_SCOPE,
|
|
154
|
+
"shared",
|
|
155
|
+
...Object.values(NATIVE_MODEL_QUOTA_SCOPES),
|
|
156
|
+
]).size;
|
|
157
|
+
|
|
158
|
+
export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
|
|
159
|
+
if (!modelId?.trim()) return undefined;
|
|
160
|
+
return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Independent quota groups must not mutate the shared active-account cursor. */
|
|
164
|
+
function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean {
|
|
165
|
+
return quotaScope !== undefined && quotaScope !== "shared";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string {
|
|
169
|
+
return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type CodexUpstreamOutcomeMeta = {
|
|
173
|
+
retryAfter?: string | null;
|
|
174
|
+
resetAt?: unknown | unknown[];
|
|
175
|
+
now?: number;
|
|
176
|
+
/** Native model selected for this request; used only for confirmed scoped quotas. */
|
|
177
|
+
modelId?: string;
|
|
178
|
+
/** When set, clears affinity for this thread immediately on transient failure. */
|
|
179
|
+
threadId?: string | null;
|
|
180
|
+
/**
|
|
181
|
+
* Probe lease held by this request, when it was admitted through an active
|
|
182
|
+
* quota cooldown. Only the outcome carrying the current lease may clear the
|
|
183
|
+
* cooldown (#433).
|
|
184
|
+
*/
|
|
185
|
+
probeLeaseId?: string;
|
|
186
|
+
/** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
|
|
187
|
+
probeQuotaScope?: CodexQuotaScope;
|
|
188
|
+
/**
|
|
189
|
+
* Already-chosen alternate for same-request 429 retry. When set, promotion
|
|
190
|
+
* reuses this account instead of calling {@link pickAlternateCodexAccount}
|
|
191
|
+
* again (which would advance a round-robin ring twice).
|
|
192
|
+
*/
|
|
193
|
+
promoteAccountId?: string;
|
|
194
|
+
/** Generation captured when this routed account was selected. */
|
|
195
|
+
writerGeneration?: number;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
|
|
199
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId);
|
|
200
|
+
return (config.codexAccounts ?? [])
|
|
201
|
+
.some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
|
|
205
|
+
const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
|
|
206
|
+
const openai = config.providers.openai;
|
|
207
|
+
if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
|
|
208
|
+
ids.add(MAIN_CODEX_ACCOUNT_ID);
|
|
209
|
+
}
|
|
210
|
+
return ids;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function clearThreadAccountMap(): void {
|
|
214
|
+
threadAccountMap.clear();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function clearThreadAccountMapForAccount(accountId: string): void {
|
|
218
|
+
for (const [threadId, affinities] of threadAccountMap) {
|
|
219
|
+
for (const [scope, entry] of affinities) {
|
|
220
|
+
if (entry.accountId === accountId) affinities.delete(scope);
|
|
221
|
+
}
|
|
222
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function clearCodexUpstreamHealth(): void {
|
|
227
|
+
upstreamHealth.clear();
|
|
228
|
+
quotaScopedHealth.clear();
|
|
229
|
+
runtimeActiveCodexAccountId = undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function clearCodexUpstreamHealthForAccount(accountId: string): void {
|
|
233
|
+
upstreamHealth.delete(accountId);
|
|
234
|
+
quotaScopedHealth.delete(accountId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function reconcileCodexRoutingHealth(context: GenerationContext): number {
|
|
238
|
+
if (context.generation <= lastReconciledGeneration) return 0;
|
|
239
|
+
let removed = 0;
|
|
240
|
+
for (const accountId of upstreamHealth.keys()) {
|
|
241
|
+
if (context.codexAccountIds.has(accountId)) continue;
|
|
242
|
+
upstreamHealth.delete(accountId);
|
|
243
|
+
removed += 1;
|
|
244
|
+
}
|
|
245
|
+
for (const accountId of quotaScopedHealth.keys()) {
|
|
246
|
+
if (context.codexAccountIds.has(accountId)) continue;
|
|
247
|
+
quotaScopedHealth.delete(accountId);
|
|
248
|
+
removed += 1;
|
|
249
|
+
}
|
|
250
|
+
liveHealthAccountIds = new Set(context.codexAccountIds);
|
|
251
|
+
lastReconciledGeneration = context.generation;
|
|
252
|
+
return removed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function getCodexUpstreamHealth(
|
|
256
|
+
accountId: string,
|
|
257
|
+
): CodexUpstreamHealth | null {
|
|
258
|
+
return upstreamHealth.get(accountId) ?? null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined {
|
|
262
|
+
return quotaScopedHealth.get(accountId)?.get(scope);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void {
|
|
266
|
+
let scopes = quotaScopedHealth.get(accountId);
|
|
267
|
+
if (!scopes) {
|
|
268
|
+
scopes = new Map();
|
|
269
|
+
quotaScopedHealth.set(accountId, scopes);
|
|
270
|
+
}
|
|
271
|
+
scopes.set(scope, health);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
|
|
275
|
+
const scopes = quotaScopedHealth.get(accountId);
|
|
276
|
+
if (!scopes) return;
|
|
277
|
+
scopes.delete(scope);
|
|
278
|
+
if (scopes.size === 0) quotaScopedHealth.delete(accountId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function computeCodexUsageScore(quota: {
|
|
282
|
+
weeklyPercent?: number;
|
|
283
|
+
monthlyPercent?: number;
|
|
284
|
+
} | null, plan?: string | null): number {
|
|
285
|
+
if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
|
|
286
|
+
const normalizedPlan = plan?.trim().toLowerCase();
|
|
287
|
+
if (normalizedPlan === "go" || normalizedPlan === "free") {
|
|
288
|
+
return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
|
|
289
|
+
? quota.monthlyPercent
|
|
290
|
+
: CODEX_UNKNOWN_USAGE_SCORE;
|
|
291
|
+
}
|
|
292
|
+
const values = [quota.weeklyPercent, quota.monthlyPercent]
|
|
293
|
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
294
|
+
return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
|
|
298
|
+
if (outcome === "connect_error" || outcome === "timeout") return "transient";
|
|
299
|
+
if (!Number.isFinite(outcome)) return "unknown";
|
|
300
|
+
if (outcome >= 200 && outcome < 300) return "success";
|
|
301
|
+
if (outcome === 401 || outcome === 403) return "credential";
|
|
302
|
+
// 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
|
|
303
|
+
// (same-request alternate retry records this outcome for the depleted account).
|
|
304
|
+
if (outcome === 429 || outcome === 402) return "quota";
|
|
305
|
+
if (outcome >= 400 && outcome < 500) return "caller";
|
|
306
|
+
if (outcome >= 500 && outcome < 600) return "transient";
|
|
307
|
+
return "unknown";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function clampCooldownMs(ms: number): number {
|
|
311
|
+
return Math.min(Math.max(ms, 1), CODEX_MAX_QUOTA_COOLDOWN_MS);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function parseRetryAfterMs(value: string | null | undefined, now = Date.now()): number | undefined {
|
|
315
|
+
const text = value?.trim();
|
|
316
|
+
if (!text) return undefined;
|
|
317
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
318
|
+
const seconds = Number(text);
|
|
319
|
+
if (Number.isFinite(seconds) && seconds > 0) return clampCooldownMs(Math.ceil(seconds * 1000));
|
|
320
|
+
}
|
|
321
|
+
const timestamp = Date.parse(text);
|
|
322
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
323
|
+
const delay = timestamp - now;
|
|
324
|
+
return delay > 0 ? clampCooldownMs(delay) : undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function resetTimestampMs(value: unknown): number | undefined {
|
|
328
|
+
const numeric = typeof value === "number"
|
|
329
|
+
? value
|
|
330
|
+
: typeof value === "string" && value.trim() !== ""
|
|
331
|
+
? Number(value)
|
|
332
|
+
: undefined;
|
|
333
|
+
if (typeof numeric !== "number" || !Number.isFinite(numeric) || numeric <= 0) return undefined;
|
|
334
|
+
return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function parseResetCooldownMs(resetAt: unknown | unknown[] | undefined, now = Date.now()): number | undefined {
|
|
338
|
+
const values = Array.isArray(resetAt) ? resetAt : [resetAt];
|
|
339
|
+
let best: number | undefined;
|
|
340
|
+
for (const value of values) {
|
|
341
|
+
const timestamp = resetTimestampMs(value);
|
|
342
|
+
if (timestamp === undefined) continue;
|
|
343
|
+
const delay = timestamp - now;
|
|
344
|
+
if (delay <= 0) continue;
|
|
345
|
+
// A far-future reset must not pin the account for the full Retry-After
|
|
346
|
+
// ceiling: quota usually frees up well before the advertised window (#433).
|
|
347
|
+
const clamped = Math.min(clampCooldownMs(delay), CODEX_MAX_RESET_DERIVED_COOLDOWN_MS);
|
|
348
|
+
if (best === undefined || clamped < best) best = clamped;
|
|
349
|
+
}
|
|
350
|
+
return best;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
|
|
354
|
+
until: number;
|
|
355
|
+
source: CodexCooldownSource;
|
|
356
|
+
} {
|
|
357
|
+
const now = meta.now ?? Date.now();
|
|
358
|
+
const retryAfterMs = parseRetryAfterMs(meta.retryAfter, now);
|
|
359
|
+
if (retryAfterMs !== undefined) return { until: now + retryAfterMs, source: "retry-after" };
|
|
360
|
+
const resetCooldownMs = parseResetCooldownMs(meta.resetAt, now);
|
|
361
|
+
if (resetCooldownMs !== undefined) return { until: now + resetCooldownMs, source: "reset-derived" };
|
|
362
|
+
return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
|
|
366
|
+
return computeQuotaCooldown(meta).until;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Grant at most one probe lease per interval for a cooled-down account.
|
|
371
|
+
*
|
|
372
|
+
* A cooled-down account is short-circuited locally, so it never sends traffic and
|
|
373
|
+
* no organic 2xx can prove that upstream quota recovered — the cooldown can only
|
|
374
|
+
* end by expiry or a proxy restart (#433). Releasing a single probe breaks that
|
|
375
|
+
* deadlock. Explicit Retry-After cooldowns are excluded: those are literal retry
|
|
376
|
+
* directives, not window announcements.
|
|
377
|
+
*
|
|
378
|
+
* Returns the lease id, or null when no probe may go out right now.
|
|
379
|
+
*/
|
|
380
|
+
export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null {
|
|
381
|
+
if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null;
|
|
382
|
+
const health = upstreamHealth.get(accountId)!;
|
|
383
|
+
const probeLeaseId = randomUUID();
|
|
384
|
+
upstreamHealth.set(accountId, {
|
|
385
|
+
...health,
|
|
386
|
+
probeLeaseId,
|
|
387
|
+
probeLeaseGeneration: health.cooldownGeneration ?? 0,
|
|
388
|
+
lastProbeAt: now,
|
|
389
|
+
});
|
|
390
|
+
return probeLeaseId;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */
|
|
394
|
+
export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean {
|
|
395
|
+
return canAcquireQuotaProbeLease(upstreamHealth.get(accountId), now);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean {
|
|
399
|
+
if (!health) return false;
|
|
400
|
+
const cooldownUntil = health.cooldownUntil;
|
|
401
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
|
|
402
|
+
if (health.cooldownSource === "retry-after") return false;
|
|
403
|
+
if (health.probeLeaseId !== undefined) return false;
|
|
404
|
+
const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
|
|
405
|
+
return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Acquire the recovery probe for one confirmed model-specific quota group. */
|
|
409
|
+
export function tryAcquireCodexQuotaScopeProbeLease(
|
|
410
|
+
accountId: string,
|
|
411
|
+
scope: CodexQuotaScope,
|
|
412
|
+
now = Date.now(),
|
|
413
|
+
): string | null {
|
|
414
|
+
const health = scopedHealthFor(accountId, scope);
|
|
415
|
+
if (!canAcquireQuotaProbeLease(health, now)) return null;
|
|
416
|
+
const probeLeaseId = randomUUID();
|
|
417
|
+
setScopedHealth(accountId, scope, {
|
|
418
|
+
...health!,
|
|
419
|
+
probeLeaseId,
|
|
420
|
+
probeLeaseGeneration: health!.cooldownGeneration ?? 0,
|
|
421
|
+
lastProbeAt: now,
|
|
422
|
+
});
|
|
423
|
+
return probeLeaseId;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Hand a probe lease back without recording an upstream outcome. Used by paths
|
|
428
|
+
* that take a lease and then fail before any request reaches upstream.
|
|
429
|
+
*/
|
|
430
|
+
export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string, now = Date.now()): void {
|
|
431
|
+
const health = upstreamHealth.get(accountId);
|
|
432
|
+
if (!health || health.probeLeaseId !== leaseId) return;
|
|
433
|
+
upstreamHealth.set(accountId, withProbeLeaseReleased(health, now));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Release a model-specific quota probe when the request never reaches upstream. */
|
|
437
|
+
export function releaseCodexQuotaScopeProbeLease(
|
|
438
|
+
accountId: string,
|
|
439
|
+
scope: CodexQuotaScope,
|
|
440
|
+
leaseId: string,
|
|
441
|
+
now = Date.now(),
|
|
442
|
+
): void {
|
|
443
|
+
const health = scopedHealthFor(accountId, scope);
|
|
444
|
+
if (!health || health.probeLeaseId !== leaseId) return;
|
|
445
|
+
setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* True when this outcome belongs to the account's in-flight probe. The
|
|
450
|
+
* undefined-id guard matters: without it an outcome carrying no lease would match
|
|
451
|
+
* an account holding no lease and be mistaken for the probe owner.
|
|
452
|
+
*/
|
|
453
|
+
function ownsProbeLease(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
|
|
454
|
+
return meta.probeLeaseId !== undefined && meta.probeLeaseId === health?.probeLeaseId;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* True when the owning probe may still clear the cooldown. A later 429 bumps the
|
|
459
|
+
* generation, so a probe that started under an older cooldown must not erase the
|
|
460
|
+
* newer restriction (which may carry an explicit Retry-After).
|
|
461
|
+
*/
|
|
462
|
+
function probeMayClearCooldown(health: CodexUpstreamHealth | undefined, meta: CodexUpstreamOutcomeMeta): boolean {
|
|
463
|
+
return ownsProbeLease(health, meta)
|
|
464
|
+
&& (health!.probeLeaseGeneration ?? 0) === (health!.cooldownGeneration ?? 0);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Strip the in-flight lease while preserving every hard-cooldown field. */
|
|
468
|
+
function withProbeLeaseReleased(health: CodexUpstreamHealth, now: number): CodexUpstreamHealth {
|
|
469
|
+
const { probeLeaseId: _id, probeLeaseGeneration: _gen, ...rest } = health;
|
|
470
|
+
return { ...rest, lastProbeAt: now };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Hard-cooldown bookkeeping that ordinary success/transient transitions rebuild
|
|
475
|
+
* their health object from. Dropping these would let one late unrelated response
|
|
476
|
+
* erase a Retry-After source, a cooldown generation, or someone else's live probe.
|
|
477
|
+
*/
|
|
478
|
+
function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Partial<CodexUpstreamHealth> {
|
|
479
|
+
if (!health) return {};
|
|
480
|
+
const { consecutiveFailures: _f, consecutiveSuccesses: _s, lastFailureStatus: _st, lastFailureAt: _at, softAvoidUntil: _sa, ...cooldownFields } = health;
|
|
481
|
+
return cooldownFields;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
|
|
485
|
+
export function resetCodexRoutingForManualSelection(accountId: string): void {
|
|
486
|
+
clearThreadAccountMap();
|
|
487
|
+
// Manual selection is the operator source of truth — drop any automatic runtime cursor.
|
|
488
|
+
runtimeActiveCodexAccountId = undefined;
|
|
489
|
+
// Seed the RR ring so the next unbound new session honors the manually selected account
|
|
490
|
+
// under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
|
|
491
|
+
// config.activeCodexAccountId, which the caller persists before invoking this.
|
|
492
|
+
seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
|
|
493
|
+
for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
|
|
494
|
+
if (isIndependentCodexQuotaScope(scope)) {
|
|
495
|
+
seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const current = upstreamHealth.get(accountId);
|
|
499
|
+
if (!current) return;
|
|
500
|
+
const preserved = preservedCooldownFields(current);
|
|
501
|
+
if (Object.keys(preserved).length === 0) upstreamHealth.delete(accountId);
|
|
502
|
+
else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
|
|
506
|
+
const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
|
|
507
|
+
return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
|
|
511
|
+
export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
|
|
512
|
+
cooldownUntil?: number;
|
|
513
|
+
cooldownSource?: CodexCooldownSource;
|
|
514
|
+
} | null {
|
|
515
|
+
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
516
|
+
if (cooldownUntil === null) return null;
|
|
517
|
+
const source = upstreamHealth.get(accountId)?.cooldownSource;
|
|
518
|
+
return {
|
|
519
|
+
cooldownUntil,
|
|
520
|
+
...(source ? { cooldownSource: source } : {}),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Read the cooldown relevant to a routed native model. Account-wide cooldowns
|
|
526
|
+
* (Retry-After/default) always win; reset-derived scoped state applies only to
|
|
527
|
+
* its confirmed quota group.
|
|
528
|
+
*/
|
|
529
|
+
export function getCodexQuotaHealthSnapshot(
|
|
530
|
+
accountId: string,
|
|
531
|
+
quotaScope: CodexQuotaScope | undefined,
|
|
532
|
+
now = Date.now(),
|
|
533
|
+
): {
|
|
534
|
+
cooldownUntil?: number;
|
|
535
|
+
cooldownSource?: CodexCooldownSource;
|
|
536
|
+
quotaScope?: CodexQuotaScope;
|
|
537
|
+
} | null {
|
|
538
|
+
const account = getCodexAccountHealthSnapshot(accountId, now);
|
|
539
|
+
if (account) return account;
|
|
540
|
+
if (!quotaScope) return null;
|
|
541
|
+
const scoped = scopedHealthFor(accountId, quotaScope);
|
|
542
|
+
const cooldownUntil = scoped?.cooldownUntil;
|
|
543
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
|
|
544
|
+
return {
|
|
545
|
+
cooldownUntil,
|
|
546
|
+
...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}),
|
|
547
|
+
quotaScope,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
|
|
552
|
+
return getCodexAccountCooldownUntil(accountId, now) !== null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Manually lift a hard quota cooldown without touching failure history.
|
|
557
|
+
*
|
|
558
|
+
* Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
|
|
559
|
+
* cooldown that outlives the real upstream limit reads to the user as "the whole app is
|
|
560
|
+
* broken" with no escape but editing config.toml. This is that escape hatch.
|
|
561
|
+
*
|
|
562
|
+
* Deliberately narrow:
|
|
563
|
+
* - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
|
|
564
|
+
* moved", not "this account is healthy"; failover must keep its knowledge.
|
|
565
|
+
* - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
|
|
566
|
+
* recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
|
|
567
|
+
* `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
|
|
568
|
+
* bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
|
|
569
|
+
* today and is kept so the invariant survives a future change that retains the lease.
|
|
570
|
+
*
|
|
571
|
+
* Returns false when the account carried no live cooldown (already expired or never set).
|
|
572
|
+
*/
|
|
573
|
+
export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
|
|
574
|
+
const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
|
|
575
|
+
const cooldownUntil = health.cooldownUntil;
|
|
576
|
+
if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
|
|
577
|
+
const {
|
|
578
|
+
cooldownUntil: _until,
|
|
579
|
+
cooldownSince: _since,
|
|
580
|
+
cooldownSource: _source,
|
|
581
|
+
probeLeaseId: _leaseId,
|
|
582
|
+
probeLeaseGeneration: _leaseGeneration,
|
|
583
|
+
...rest
|
|
584
|
+
} = health;
|
|
585
|
+
return {
|
|
586
|
+
...rest,
|
|
587
|
+
cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
|
|
588
|
+
lastProbeAt: now,
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
let cleared = false;
|
|
593
|
+
const accountHealth = upstreamHealth.get(accountId);
|
|
594
|
+
if (accountHealth) {
|
|
595
|
+
const next = clear(accountHealth);
|
|
596
|
+
if (next) {
|
|
597
|
+
upstreamHealth.set(accountId, next);
|
|
598
|
+
cleared = true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) {
|
|
602
|
+
const next = clear(health);
|
|
603
|
+
if (next) {
|
|
604
|
+
setScopedHealth(accountId, scope, next);
|
|
605
|
+
cleared = true;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return cleared;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
|
|
612
|
+
const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
|
|
613
|
+
return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
|
|
614
|
+
? softAvoidUntil
|
|
615
|
+
: null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
|
|
619
|
+
return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function isCodexAccountSelectable(
|
|
623
|
+
config: OcxConfig,
|
|
624
|
+
accountId: string,
|
|
625
|
+
now: number,
|
|
626
|
+
quotaScope?: CodexQuotaScope,
|
|
627
|
+
): boolean {
|
|
628
|
+
return !isCodexAccountPaused(config, accountId)
|
|
629
|
+
&& getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
|
|
630
|
+
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
631
|
+
&& isCodexAccountUsable(config, accountId);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
|
|
635
|
+
return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function admissibleAffinityComponent(value: string): boolean {
|
|
639
|
+
return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
|
|
643
|
+
if (!admissibleAffinityComponent(threadId)) return undefined;
|
|
644
|
+
return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
|
|
648
|
+
if (!admissibleAffinityComponent(threadId)) return;
|
|
649
|
+
const affinities = threadAccountMap.get(threadId);
|
|
650
|
+
if (!affinities) return;
|
|
651
|
+
affinities.delete(threadAffinityScope(quotaScope));
|
|
652
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Remove only the matching failed account's affinities for one thread. */
|
|
656
|
+
function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
|
|
657
|
+
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
658
|
+
const affinities = threadAccountMap.get(threadId);
|
|
659
|
+
if (!affinities) return;
|
|
660
|
+
for (const [scope, entry] of affinities) {
|
|
661
|
+
if (entry.accountId === accountId) affinities.delete(scope);
|
|
662
|
+
}
|
|
663
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function threadAffinityEntryCount(): number {
|
|
667
|
+
let count = 0;
|
|
668
|
+
for (const affinities of threadAccountMap.values()) count += affinities.size;
|
|
669
|
+
return count;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
|
|
673
|
+
return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
|
|
677
|
+
if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0;
|
|
678
|
+
return isCodexAccountGenerationLive(entry.accountId, entry.generation);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function pruneExpiredThreadAffinities(now: number): void {
|
|
682
|
+
for (const [threadId, affinities] of threadAccountMap) {
|
|
683
|
+
for (const [scope, entry] of affinities) {
|
|
684
|
+
if (isThreadAffinityExpired(entry, now)) affinities.delete(scope);
|
|
685
|
+
}
|
|
686
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function pruneLruThreadAffinities(): void {
|
|
691
|
+
if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
|
|
692
|
+
while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
|
|
693
|
+
let oldestThreadId: string | null = null;
|
|
694
|
+
let oldestScope: ThreadAffinityScope | null = null;
|
|
695
|
+
let oldestLastUsedAt = Number.POSITIVE_INFINITY;
|
|
696
|
+
for (const [threadId, affinities] of threadAccountMap) {
|
|
697
|
+
for (const [scope, entry] of affinities) {
|
|
698
|
+
if (entry.lastUsedAt < oldestLastUsedAt) {
|
|
699
|
+
oldestThreadId = threadId;
|
|
700
|
+
oldestScope = scope;
|
|
701
|
+
oldestLastUsedAt = entry.lastUsedAt;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (!oldestThreadId || !oldestScope) return;
|
|
706
|
+
deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function bindThreadAffinity(
|
|
711
|
+
threadId: string,
|
|
712
|
+
accountId: string,
|
|
713
|
+
now: number,
|
|
714
|
+
quotaScope?: CodexQuotaScope,
|
|
715
|
+
): void {
|
|
716
|
+
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
717
|
+
const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
|
|
718
|
+
if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
|
|
719
|
+
pruneExpiredThreadAffinities(now);
|
|
720
|
+
const scope = threadAffinityScope(quotaScope);
|
|
721
|
+
const affinities = threadAccountMap.get(threadId) ?? new Map<ThreadAffinityScope, ThreadAffinityEntry>();
|
|
722
|
+
const previous = affinities.get(scope);
|
|
723
|
+
affinities.set(scope, {
|
|
724
|
+
accountId,
|
|
725
|
+
generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation,
|
|
726
|
+
createdAt: previous?.createdAt ?? now,
|
|
727
|
+
lastUsedAt: now,
|
|
728
|
+
lastReevalAt: now,
|
|
729
|
+
});
|
|
730
|
+
threadAccountMap.set(threadId, affinities);
|
|
731
|
+
pruneLruThreadAffinities();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function getEligiblePoolAccounts(
|
|
735
|
+
config: OcxConfig,
|
|
736
|
+
excludeId?: string,
|
|
737
|
+
now = Date.now(),
|
|
738
|
+
quotaScope?: CodexQuotaScope,
|
|
739
|
+
): string[] {
|
|
740
|
+
const ids = (config.codexAccounts ?? [])
|
|
741
|
+
.filter(account => isSelectableCodexPoolAccount(account)
|
|
742
|
+
&& account.id !== excludeId
|
|
743
|
+
&& !isCodexAccountPaused(config, account.id)
|
|
744
|
+
&& !isAccountNeedsReauth(account.id))
|
|
745
|
+
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
|
746
|
+
.filter(account => !isCodexAccountSoftAvoided(account.id, now))
|
|
747
|
+
.filter(account => isCodexAccountUsable(config, account.id))
|
|
748
|
+
.map(account => account.id);
|
|
749
|
+
// The main Codex account is not stored in config.codexAccounts; include it as a
|
|
750
|
+
// first-class rotation candidate when its read-only token is usable (Option A).
|
|
751
|
+
if (
|
|
752
|
+
excludeId !== MAIN_CODEX_ACCOUNT_ID
|
|
753
|
+
&& !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
754
|
+
&& !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
|
|
755
|
+
&& getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
|
|
756
|
+
&& !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
|
|
757
|
+
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
|
|
758
|
+
) {
|
|
759
|
+
ids.unshift(MAIN_CODEX_ACCOUNT_ID);
|
|
760
|
+
}
|
|
761
|
+
return ids;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function listEligibleCodexAccountIds(
|
|
765
|
+
config: OcxConfig,
|
|
766
|
+
now: number,
|
|
767
|
+
quotaScope?: CodexQuotaScope,
|
|
768
|
+
): string[] {
|
|
769
|
+
return getEligiblePoolAccounts(config, undefined, now, quotaScope);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function stickyLimitForConfig(config: OcxConfig): number {
|
|
773
|
+
return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
|
|
777
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
778
|
+
if (threshold <= 0) return true;
|
|
779
|
+
const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
|
|
780
|
+
// Unknown usage must not force fill-first to abandon the active account.
|
|
781
|
+
if (isUnknownUsage(usage)) return true;
|
|
782
|
+
return usage < threshold;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Fill-first: keep selectable active under threshold; otherwise advance to the next
|
|
787
|
+
* eligible id in stable sorted order after the current active (wrapping).
|
|
788
|
+
*/
|
|
789
|
+
function pickFillFirstCodexAccount(
|
|
790
|
+
config: OcxConfig,
|
|
791
|
+
now: number,
|
|
792
|
+
quotaScope?: CodexQuotaScope,
|
|
793
|
+
): string | null {
|
|
794
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
|
|
795
|
+
if (eligible.length === 0) return null;
|
|
796
|
+
|
|
797
|
+
const active = getEffectiveActiveCodexAccountId(config);
|
|
798
|
+
if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) {
|
|
799
|
+
return active;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Next eligible account in stable order after `afterId` (wrapping). */
|
|
806
|
+
function pickNextFillFirstCodexAccount(
|
|
807
|
+
config: OcxConfig,
|
|
808
|
+
afterId: string | null,
|
|
809
|
+
eligible = listEligibleCodexAccountIds(config, Date.now()),
|
|
810
|
+
_now = Date.now(),
|
|
811
|
+
): string | null {
|
|
812
|
+
if (eligible.length === 0) return null;
|
|
813
|
+
const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
|
|
814
|
+
if (!afterId) {
|
|
815
|
+
// Prefer an under-threshold account when starting with no active cursor.
|
|
816
|
+
for (const id of ordered) {
|
|
817
|
+
if (isActiveUnderFillFirstThreshold(config, id)) return id;
|
|
818
|
+
}
|
|
819
|
+
return ordered[0] ?? null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const allConfigured = [
|
|
823
|
+
...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID
|
|
824
|
+
? [MAIN_CODEX_ACCOUNT_ID]
|
|
825
|
+
: []),
|
|
826
|
+
...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
|
|
827
|
+
];
|
|
828
|
+
const stableAll = [...new Set(allConfigured)].sort((a, b) => a.localeCompare(b));
|
|
829
|
+
const startIdx = stableAll.indexOf(afterId);
|
|
830
|
+
if (startIdx < 0) {
|
|
831
|
+
for (const id of ordered) {
|
|
832
|
+
if (isActiveUnderFillFirstThreshold(config, id)) return id;
|
|
833
|
+
}
|
|
834
|
+
return ordered[0] ?? null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Skip successors that are also at/above threshold (known drained usage).
|
|
838
|
+
let fallback: string | null = null;
|
|
839
|
+
for (let step = 1; step <= stableAll.length; step++) {
|
|
840
|
+
const candidate = stableAll[(startIdx + step) % stableAll.length]!;
|
|
841
|
+
if (!eligible.includes(candidate)) continue;
|
|
842
|
+
if (!fallback) fallback = candidate;
|
|
843
|
+
if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
|
|
844
|
+
}
|
|
845
|
+
return fallback ?? ordered[0] ?? null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Unbound new-session pick for round-robin / fill-first. Returns null to fall through
|
|
850
|
+
* to the legacy quota path (or when the strategy is quota).
|
|
851
|
+
*
|
|
852
|
+
* When `commit` is true (resolve path), remembers active in-memory, binds thread affinity, and
|
|
853
|
+
* notes RR success. When `commit` is false (preview), returns the same RR/fill-first
|
|
854
|
+
* account resolve would pick via a dry-run peek — without mutating ring weights,
|
|
855
|
+
* activeKey, sticky counters, config, or affinity.
|
|
856
|
+
*
|
|
857
|
+
* Automatic strategy picks never sync-write config; only manual selection persists active.
|
|
858
|
+
*
|
|
859
|
+
* Known limitation (follow-up): when a subagent preview peeks an RR account and the request
|
|
860
|
+
* then falls back to a non-Codex provider, the ring is not reserved/committed. Prefer seeding
|
|
861
|
+
* the peeked account if that path becomes load-bearing.
|
|
862
|
+
*/
|
|
863
|
+
function pickUnboundStrategyAccount(
|
|
864
|
+
config: OcxConfig,
|
|
865
|
+
threadId: string | null,
|
|
866
|
+
now: number,
|
|
867
|
+
commit: boolean,
|
|
868
|
+
quotaScope?: CodexQuotaScope,
|
|
869
|
+
): string | null {
|
|
870
|
+
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
871
|
+
if (strategy === "quota") return null;
|
|
872
|
+
const poolKey = codexPoolKeyForScope(quotaScope);
|
|
873
|
+
|
|
874
|
+
let picked: string | null = null;
|
|
875
|
+
if (strategy === "round-robin") {
|
|
876
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
|
|
877
|
+
const limit = stickyLimitForConfig(config);
|
|
878
|
+
if (!commit) {
|
|
879
|
+
return peekRoundRobinAccount(poolKey, eligible, limit);
|
|
880
|
+
}
|
|
881
|
+
picked = pickRoundRobinAccount(poolKey, eligible, limit);
|
|
882
|
+
if (!picked) return null;
|
|
883
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
|
|
884
|
+
if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
885
|
+
notePoolRotationSuccess(poolKey, picked, limit);
|
|
886
|
+
return picked;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (strategy === "fill-first") {
|
|
890
|
+
picked = pickFillFirstCodexAccount(config, now, quotaScope);
|
|
891
|
+
if (!picked) return null;
|
|
892
|
+
if (commit) {
|
|
893
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
|
|
894
|
+
if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
|
|
895
|
+
}
|
|
896
|
+
return picked;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
|
|
903
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
|
|
904
|
+
return (config.codexAccounts ?? [])
|
|
905
|
+
.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function pickLowerUsageAccount(
|
|
909
|
+
config: OcxConfig,
|
|
910
|
+
active: string,
|
|
911
|
+
activeUsage: number,
|
|
912
|
+
now: number,
|
|
913
|
+
quotaScope?: CodexQuotaScope,
|
|
914
|
+
): string {
|
|
915
|
+
let best = active;
|
|
916
|
+
let bestUsage = activeUsage;
|
|
917
|
+
for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) {
|
|
918
|
+
const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
|
|
919
|
+
if (usage < bestUsage) {
|
|
920
|
+
best = id;
|
|
921
|
+
bestUsage = usage;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return best;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export function pickLowestUsageCodexAccount(
|
|
928
|
+
config: OcxConfig,
|
|
929
|
+
excludeId?: string,
|
|
930
|
+
now = Date.now(),
|
|
931
|
+
quotaScope?: CodexQuotaScope,
|
|
932
|
+
): string | null {
|
|
933
|
+
let best: string | null = null;
|
|
934
|
+
let bestUsage = Number.POSITIVE_INFINITY;
|
|
935
|
+
for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) {
|
|
936
|
+
const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
|
|
937
|
+
if (usage < bestUsage) {
|
|
938
|
+
best = id;
|
|
939
|
+
bestUsage = usage;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return best;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
|
|
947
|
+
* and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
|
|
948
|
+
* round-robin takes the next ring pick (caller should have noted the failure).
|
|
949
|
+
*/
|
|
950
|
+
export function pickAlternateCodexAccount(
|
|
951
|
+
config: OcxConfig,
|
|
952
|
+
excludeId: string,
|
|
953
|
+
now = Date.now(),
|
|
954
|
+
quotaScope?: CodexQuotaScope,
|
|
955
|
+
): string | null {
|
|
956
|
+
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
957
|
+
if (strategy === "round-robin") {
|
|
958
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
|
|
959
|
+
return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
|
|
960
|
+
}
|
|
961
|
+
if (strategy === "fill-first") {
|
|
962
|
+
const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
|
|
963
|
+
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now);
|
|
964
|
+
}
|
|
965
|
+
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Effective active: automatic runtime cursor, else operator/persisted selection. */
|
|
969
|
+
export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
|
|
970
|
+
return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
|
|
975
|
+
* so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
|
|
976
|
+
*/
|
|
977
|
+
function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void {
|
|
978
|
+
runtimeActiveCodexAccountId = accountId;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Persist operator (or quota-strategy) active selection to config + disk. */
|
|
982
|
+
function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
983
|
+
runtimeActiveCodexAccountId = undefined;
|
|
984
|
+
if (config.activeCodexAccountId === accountId) return;
|
|
985
|
+
config.activeCodexAccountId = accountId;
|
|
986
|
+
saveConfigPreservingClaudeCode(config);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
|
|
990
|
+
function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
991
|
+
if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
|
|
992
|
+
setActiveCodexAccount(config, accountId);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
rememberActiveCodexAccount(config, accountId);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Reconcile the effective active account after an administrative exclusion such as pause.
|
|
1000
|
+
* The operator's persisted selection is cleared when it names the excluded account; quota
|
|
1001
|
+
* keeps its historical persisted promotion, while rotating strategies retain the replacement
|
|
1002
|
+
* only in the process-local cursor.
|
|
1003
|
+
*/
|
|
1004
|
+
export function reconcileCodexActiveAfterExclusion(
|
|
1005
|
+
config: OcxConfig,
|
|
1006
|
+
excludedAccountId: string,
|
|
1007
|
+
now = Date.now(),
|
|
1008
|
+
): string | null {
|
|
1009
|
+
const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId;
|
|
1010
|
+
if (config.activeCodexAccountId === excludedAccountId) {
|
|
1011
|
+
config.activeCodexAccountId = undefined;
|
|
1012
|
+
}
|
|
1013
|
+
if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null;
|
|
1014
|
+
|
|
1015
|
+
runtimeActiveCodexAccountId = undefined;
|
|
1016
|
+
const fallback = pickAlternateCodexAccount(config, excludedAccountId, now);
|
|
1017
|
+
if (fallback) promoteActiveCodexAccount(config, fallback);
|
|
1018
|
+
return fallback;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function isUnknownUsage(usage: number): boolean {
|
|
1022
|
+
return usage >= CODEX_UNKNOWN_USAGE_SCORE;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function applyQuotaAutoSwitch(
|
|
1026
|
+
config: OcxConfig,
|
|
1027
|
+
active: string,
|
|
1028
|
+
now: number,
|
|
1029
|
+
quotaScope?: CodexQuotaScope,
|
|
1030
|
+
): string {
|
|
1031
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1032
|
+
if (threshold <= 0) return active;
|
|
1033
|
+
const quota = getAccountQuota(active);
|
|
1034
|
+
const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
|
|
1035
|
+
// Unknown usage is not evidence that a user's explicit selection crossed the
|
|
1036
|
+
// threshold. Wait for quota priming instead of rotating among guesses.
|
|
1037
|
+
if (isUnknownUsage(activeUsage)) return active;
|
|
1038
|
+
if (activeUsage < threshold) return active;
|
|
1039
|
+
const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope);
|
|
1040
|
+
if (best !== active) {
|
|
1041
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
|
|
1042
|
+
return best;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return active;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
|
|
1049
|
+
const threshold = config.upstreamFailoverThreshold ?? 3;
|
|
1050
|
+
if (threshold <= 0) return false;
|
|
1051
|
+
const health = upstreamHealth.get(accountId);
|
|
1052
|
+
if (health?.lastFailureAt && now - health.lastFailureAt > CODEX_FAILURE_WINDOW_MS) return false;
|
|
1053
|
+
return !!health && health.consecutiveFailures >= threshold;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function applyFailureFailover(config: OcxConfig, active: string, now: number): string {
|
|
1057
|
+
if (!shouldFailover(config, active, now)) return active;
|
|
1058
|
+
const best = pickAlternateCodexAccount(config, active, now);
|
|
1059
|
+
if (best) {
|
|
1060
|
+
promoteActiveCodexAccount(config, best);
|
|
1061
|
+
return best;
|
|
1062
|
+
}
|
|
1063
|
+
return active;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export function resolveCodexAccountForThread(
|
|
1067
|
+
threadId: string | null,
|
|
1068
|
+
config: OcxConfig,
|
|
1069
|
+
now = Date.now(),
|
|
1070
|
+
quotaScope?: CodexQuotaScope,
|
|
1071
|
+
): string | null {
|
|
1072
|
+
const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope);
|
|
1073
|
+
return resolution.status === "selected" ? resolution.accountId : null;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Side-effect-free preview of the Codex pool account native routing would prefer.
|
|
1078
|
+
* Used for subagent fallback quota decisions before final auth.
|
|
1079
|
+
*
|
|
1080
|
+
* Does not mutate activeCodexAccountId, thread affinity, config on disk, or probe leases.
|
|
1081
|
+
* Mirrors {@link resolveCodexAccountForThreadDetailed} account choice, including returning a
|
|
1082
|
+
* configured cooled account so callers can evaluate probe/quota availability.
|
|
1083
|
+
*/
|
|
1084
|
+
export function previewCodexAccountForRequest(
|
|
1085
|
+
threadId: string | null,
|
|
1086
|
+
config: OcxConfig,
|
|
1087
|
+
now = Date.now(),
|
|
1088
|
+
quotaScope?: CodexQuotaScope,
|
|
1089
|
+
): string | null {
|
|
1090
|
+
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1091
|
+
if (threadId && entry) {
|
|
1092
|
+
if (
|
|
1093
|
+
!isThreadAffinityExpired(entry, now)
|
|
1094
|
+
&& isThreadAffinityGenerationLive(entry)
|
|
1095
|
+
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
|
|
1096
|
+
&& !shouldFailover(config, entry.accountId, now)
|
|
1097
|
+
) {
|
|
1098
|
+
// Quota strategy only: non-quota strategies keep affinity for ongoing threads
|
|
1099
|
+
// (new-session-only rotation — docs / affinity policy A).
|
|
1100
|
+
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
1101
|
+
if (strategy === "quota") {
|
|
1102
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1103
|
+
if (threshold > 0) {
|
|
1104
|
+
const usage = computeCodexUsageScore(
|
|
1105
|
+
getAccountQuota(entry.accountId),
|
|
1106
|
+
getPoolAccountPlan(config, entry.accountId),
|
|
1107
|
+
);
|
|
1108
|
+
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1109
|
+
const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
|
|
1110
|
+
if (best !== entry.accountId) return best;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return entry.accountId;
|
|
1115
|
+
}
|
|
1116
|
+
// Stale/unusable affinity is ignored for preview (no map mutation).
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false, quotaScope);
|
|
1120
|
+
if (strategyPick) return strategyPick;
|
|
1121
|
+
|
|
1122
|
+
let active = getEffectiveActiveCodexAccountId(config) ?? null;
|
|
1123
|
+
if (!active) {
|
|
1124
|
+
return pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
|
|
1125
|
+
}
|
|
1126
|
+
if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
|
|
1127
|
+
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
|
|
1128
|
+
if (fallback) active = fallback;
|
|
1129
|
+
else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active;
|
|
1130
|
+
else return null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1134
|
+
if (threshold > 0) {
|
|
1135
|
+
const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
|
|
1136
|
+
if (!isUnknownUsage(usage) && usage >= threshold) {
|
|
1137
|
+
active = pickLowerUsageAccount(config, active, usage, now, quotaScope);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if (shouldFailover(config, active, now)) {
|
|
1141
|
+
const best = pickLowestUsageCodexAccount(config, active, now, quotaScope);
|
|
1142
|
+
if (best) active = best;
|
|
1143
|
+
}
|
|
1144
|
+
if (!isCodexAccountUsable(config, active)) {
|
|
1145
|
+
return hasConfiguredPoolAccount(config, active) ? active : null;
|
|
1146
|
+
}
|
|
1147
|
+
if (isCodexAccountPaused(config, active)) return null;
|
|
1148
|
+
if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
|
|
1149
|
+
return hasConfiguredPoolAccount(config, active) ? active : null;
|
|
1150
|
+
}
|
|
1151
|
+
return active;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export function resolveCodexAccountForThreadDetailed(
|
|
1155
|
+
threadId: string | null,
|
|
1156
|
+
config: OcxConfig,
|
|
1157
|
+
now = Date.now(),
|
|
1158
|
+
quotaScope?: CodexQuotaScope,
|
|
1159
|
+
): CodexThreadResolution {
|
|
1160
|
+
const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
|
|
1161
|
+
if (threadId && entry) {
|
|
1162
|
+
if (isThreadAffinityExpired(entry, now)) {
|
|
1163
|
+
deleteThreadAffinity(threadId, quotaScope);
|
|
1164
|
+
return { status: "expired", accountId: entry.accountId };
|
|
1165
|
+
}
|
|
1166
|
+
if (
|
|
1167
|
+
isThreadAffinityGenerationLive(entry)
|
|
1168
|
+
&& isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
|
|
1169
|
+
// Affined threads must leave a failing account once the streak trips failover
|
|
1170
|
+
// (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
|
|
1171
|
+
&& !shouldFailover(config, entry.accountId, now)
|
|
1172
|
+
) {
|
|
1173
|
+
entry.lastUsedAt = now;
|
|
1174
|
+
// Periodic quota re-eval: a long-lived bound thread must still switch when
|
|
1175
|
+
// it crosses autoSwitchThreshold and a strictly-cooler account exists.
|
|
1176
|
+
// Without this the reuse branch returns before applyQuotaAutoSwitch and the
|
|
1177
|
+
// thread stays pinned for the full idle TTL (the WSL "never switches" report).
|
|
1178
|
+
// Over-threshold pins re-eval immediately so a depleted primary does not keep
|
|
1179
|
+
// serving for up to 60s after a secondary with quota is available (#584).
|
|
1180
|
+
// Non-quota strategies (RR / fill-first) keep affinity for ongoing threads —
|
|
1181
|
+
// rotation is new-session-only (affinity policy A).
|
|
1182
|
+
const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
|
|
1183
|
+
if (strategy === "quota") {
|
|
1184
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
1185
|
+
const usage = threshold > 0
|
|
1186
|
+
? computeCodexUsageScore(
|
|
1187
|
+
getAccountQuota(entry.accountId),
|
|
1188
|
+
getPoolAccountPlan(config, entry.accountId),
|
|
1189
|
+
)
|
|
1190
|
+
: 0;
|
|
1191
|
+
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
1192
|
+
if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
|
|
1193
|
+
entry.lastReevalAt = now;
|
|
1194
|
+
if (overThreshold) {
|
|
1195
|
+
const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
|
|
1196
|
+
if (best !== entry.accountId) {
|
|
1197
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
|
|
1198
|
+
bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
|
|
1199
|
+
return { status: "selected", accountId: best };
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return { status: "selected", accountId: entry.accountId };
|
|
1205
|
+
}
|
|
1206
|
+
deleteThreadAffinity(threadId, quotaScope);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope);
|
|
1210
|
+
if (strategyPick) return { status: "selected", accountId: strategyPick };
|
|
1211
|
+
|
|
1212
|
+
let active = getEffectiveActiveCodexAccountId(config);
|
|
1213
|
+
if (!active) {
|
|
1214
|
+
const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
|
|
1215
|
+
if (!selected) return { status: "none" };
|
|
1216
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
|
|
1217
|
+
active = selected;
|
|
1218
|
+
}
|
|
1219
|
+
if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
|
|
1220
|
+
const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
|
|
1221
|
+
if (fallback) {
|
|
1222
|
+
if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
|
|
1223
|
+
active = fallback;
|
|
1224
|
+
} else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) {
|
|
1225
|
+
return { status: "selected", accountId: active };
|
|
1226
|
+
} else {
|
|
1227
|
+
return { status: "none" };
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
active = applyQuotaAutoSwitch(config, active, now, quotaScope);
|
|
1231
|
+
active = applyFailureFailover(config, active, now);
|
|
1232
|
+
if (!isCodexAccountUsable(config, active)) {
|
|
1233
|
+
return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
|
|
1234
|
+
}
|
|
1235
|
+
if (isCodexAccountPaused(config, active)) return { status: "none" };
|
|
1236
|
+
if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
|
|
1237
|
+
return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
|
|
1238
|
+
}
|
|
1239
|
+
if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
|
|
1240
|
+
return { status: "selected", accountId: active };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
export function recordCodexUpstreamOutcome(
|
|
1244
|
+
config: OcxConfig,
|
|
1245
|
+
accountId: string | null,
|
|
1246
|
+
outcome: CodexUpstreamOutcome,
|
|
1247
|
+
meta: CodexUpstreamOutcomeMeta = {},
|
|
1248
|
+
): void {
|
|
1249
|
+
if (!accountId) return;
|
|
1250
|
+
const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
|
|
1251
|
+
if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
|
|
1252
|
+
const now = meta.now ?? Date.now();
|
|
1253
|
+
const outcomeClass = classifyCodexUpstreamOutcome(outcome);
|
|
1254
|
+
const quotaScope = codexQuotaScopeForModel(meta.modelId);
|
|
1255
|
+
if (outcomeClass === "success") {
|
|
1256
|
+
const scopedProbe = meta.probeQuotaScope
|
|
1257
|
+
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
1258
|
+
: undefined;
|
|
1259
|
+
if (scopedProbe && meta.probeQuotaScope) {
|
|
1260
|
+
if (scopedProbe.cooldownUntil && probeMayClearCooldown(scopedProbe, meta)) {
|
|
1261
|
+
deleteScopedHealth(accountId, meta.probeQuotaScope);
|
|
1262
|
+
} else if (ownsProbeLease(scopedProbe, meta)) {
|
|
1263
|
+
setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const current = upstreamHealth.get(accountId);
|
|
1267
|
+
const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
|
|
1268
|
+
// A leased probe that is still on its own cooldown generation proves the
|
|
1269
|
+
// account recovered: clear the hard cooldown outright (#433).
|
|
1270
|
+
if (cooldownUntil && probeMayClearCooldown(current, meta)) {
|
|
1271
|
+
upstreamHealth.delete(accountId);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
// Owning probe on a stale generation: the lease is done, but a newer 429
|
|
1275
|
+
// replaced the cooldown in the meantime, so only give the lease back.
|
|
1276
|
+
// Non-owners keep every hard-cooldown field, including someone else's live lease.
|
|
1277
|
+
const base = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
|
|
1278
|
+
const preserved = preservedCooldownFields(base);
|
|
1279
|
+
const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
|
|
1280
|
+
if (failoverEnabled && current && current.consecutiveFailures >= 2) {
|
|
1281
|
+
const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
|
|
1282
|
+
if (consecutiveSuccesses < 2) {
|
|
1283
|
+
upstreamHealth.set(accountId, {
|
|
1284
|
+
...base!,
|
|
1285
|
+
...preserved,
|
|
1286
|
+
consecutiveSuccesses,
|
|
1287
|
+
});
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
// Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
|
|
1292
|
+
// Hard quota cooldown intentionally survives either recovery path.
|
|
1293
|
+
if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
|
|
1294
|
+
else upstreamHealth.delete(accountId);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (outcomeClass === "caller") {
|
|
1298
|
+
// A 4xx does not change account health, but it does conclude an in-flight
|
|
1299
|
+
// probe — otherwise the lease would never be handed back.
|
|
1300
|
+
const current = upstreamHealth.get(accountId);
|
|
1301
|
+
const scopedProbe = meta.probeQuotaScope
|
|
1302
|
+
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
1303
|
+
: undefined;
|
|
1304
|
+
if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
|
|
1305
|
+
setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
|
|
1306
|
+
}
|
|
1307
|
+
if (ownsProbeLease(current, meta)) {
|
|
1308
|
+
upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
|
|
1309
|
+
}
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
|
|
1314
|
+
if (outcomeClass === "credential") {
|
|
1315
|
+
// 401/403 quarantines the account for reauth. That supersedes quota state
|
|
1316
|
+
// entirely: a cooldown (and any probe lease) on an unusable account is moot.
|
|
1317
|
+
upstreamHealth.set(accountId, {
|
|
1318
|
+
consecutiveFailures: 1,
|
|
1319
|
+
lastFailureStatus,
|
|
1320
|
+
lastFailureAt: now,
|
|
1321
|
+
});
|
|
1322
|
+
quotaScopedHealth.delete(accountId);
|
|
1323
|
+
markAccountNeedsReauth(accountId, writerGeneration);
|
|
1324
|
+
clearThreadAccountMapForAccount(accountId);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (outcomeClass === "quota") {
|
|
1329
|
+
const { until, source } = computeQuotaCooldown(meta);
|
|
1330
|
+
// A reset timestamp is an advisory quota-window announcement. When the
|
|
1331
|
+
// selected native model belongs to a confirmed independent group, preserve
|
|
1332
|
+
// it there so a different group (Spark versus the shared native quota) can
|
|
1333
|
+
// still reach upstream. Explicit Retry-After/default 429s remain account-wide.
|
|
1334
|
+
if (source === "reset-derived" && quotaScope) {
|
|
1335
|
+
const prior = scopedHealthFor(accountId, quotaScope);
|
|
1336
|
+
const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
|
|
1337
|
+
const ownsLease = meta.probeQuotaScope === quotaScope && ownsProbeLease(prior, meta);
|
|
1338
|
+
setScopedHealth(accountId, quotaScope, {
|
|
1339
|
+
consecutiveFailures: 0,
|
|
1340
|
+
lastFailureStatus,
|
|
1341
|
+
lastFailureAt: now,
|
|
1342
|
+
cooldownUntil: until,
|
|
1343
|
+
cooldownSince: now,
|
|
1344
|
+
cooldownSource: source,
|
|
1345
|
+
cooldownGeneration,
|
|
1346
|
+
...(ownsLease
|
|
1347
|
+
? { lastProbeAt: now }
|
|
1348
|
+
: {
|
|
1349
|
+
...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
|
|
1350
|
+
...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
|
|
1351
|
+
...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
|
|
1352
|
+
}),
|
|
1353
|
+
});
|
|
1354
|
+
// The shared native scope is the existing account-wide native behavior:
|
|
1355
|
+
// threads must leave it and new requests should prefer an eligible account.
|
|
1356
|
+
// Spark remains isolated so a same-account Terra/Luna combo fallback can run.
|
|
1357
|
+
if (quotaScope === "shared") {
|
|
1358
|
+
clearThreadAccountMapForAccount(accountId);
|
|
1359
|
+
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
|
|
1360
|
+
if (getEffectiveActiveCodexAccountId(config) === accountId) {
|
|
1361
|
+
// Same-request 429 retry already picked via excludeAccountId — reuse it so
|
|
1362
|
+
// round-robin does not advance the ring a second time.
|
|
1363
|
+
const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
|
|
1364
|
+
? meta.promoteAccountId
|
|
1365
|
+
: null;
|
|
1366
|
+
const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
|
|
1367
|
+
if (fallback) promoteActiveCodexAccount(config, fallback);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// A scoped probe that received an account-wide throttle is no longer live.
|
|
1374
|
+
const scopedProbe = meta.probeQuotaScope
|
|
1375
|
+
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
1376
|
+
: undefined;
|
|
1377
|
+
if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
|
|
1378
|
+
setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
|
|
1379
|
+
}
|
|
1380
|
+
const prior = upstreamHealth.get(accountId);
|
|
1381
|
+
// Every cooldown write bumps the generation so a probe issued against the
|
|
1382
|
+
// previous cooldown can no longer clear this one (#433).
|
|
1383
|
+
const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
|
|
1384
|
+
// A failed probe concludes its lease; an unrelated 429 leaves the live probe alone.
|
|
1385
|
+
const ownsLease = ownsProbeLease(prior, meta);
|
|
1386
|
+
upstreamHealth.set(accountId, {
|
|
1387
|
+
consecutiveFailures: 0,
|
|
1388
|
+
lastFailureStatus,
|
|
1389
|
+
lastFailureAt: now,
|
|
1390
|
+
cooldownUntil: until,
|
|
1391
|
+
cooldownSince: now,
|
|
1392
|
+
cooldownSource: source,
|
|
1393
|
+
cooldownGeneration,
|
|
1394
|
+
...(ownsLease
|
|
1395
|
+
? { lastProbeAt: now }
|
|
1396
|
+
: {
|
|
1397
|
+
...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
|
|
1398
|
+
...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
|
|
1399
|
+
...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
|
|
1400
|
+
}),
|
|
1401
|
+
});
|
|
1402
|
+
clearThreadAccountMapForAccount(accountId);
|
|
1403
|
+
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
|
|
1404
|
+
const effectiveActive = getEffectiveActiveCodexAccountId(config);
|
|
1405
|
+
if (effectiveActive === accountId) {
|
|
1406
|
+
// Same-request 429 retry already picked via excludeAccountId — reuse it so
|
|
1407
|
+
// round-robin does not advance the ring a second time.
|
|
1408
|
+
const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
|
|
1409
|
+
? meta.promoteAccountId
|
|
1410
|
+
: null;
|
|
1411
|
+
const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
|
|
1412
|
+
if (fallback) promoteActiveCodexAccount(config, fallback);
|
|
1413
|
+
}
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// transient (connect_error / timeout / 5xx)
|
|
1418
|
+
const current = upstreamHealth.get(accountId);
|
|
1419
|
+
const scopedProbe = meta.probeQuotaScope
|
|
1420
|
+
? scopedHealthFor(accountId, meta.probeQuotaScope)
|
|
1421
|
+
: undefined;
|
|
1422
|
+
if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
|
|
1423
|
+
setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
|
|
1424
|
+
}
|
|
1425
|
+
// A transient failure concludes an owning probe; an unrelated 5xx must not
|
|
1426
|
+
// consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
|
|
1427
|
+
const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
|
|
1428
|
+
const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
|
|
1429
|
+
const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
|
|
1430
|
+
// Soft avoid + affinity clears are part of failover. When threshold is 0, leave
|
|
1431
|
+
// sticky sessions alone (same as shouldFailover / applyFailureFailover no-ops).
|
|
1432
|
+
const failoverThreshold = config.upstreamFailoverThreshold ?? 3;
|
|
1433
|
+
const consecutiveFailures = stale ? 1 : (current?.consecutiveFailures ?? 0) + 1;
|
|
1434
|
+
const failoverReady = failoverThreshold > 0 && consecutiveFailures >= failoverThreshold;
|
|
1435
|
+
const escalationMs = CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS[
|
|
1436
|
+
Math.min(Math.max(consecutiveFailures - failoverThreshold, 0), CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length - 1)
|
|
1437
|
+
]!;
|
|
1438
|
+
const softAvoidUntil = failoverReady
|
|
1439
|
+
? Math.max(
|
|
1440
|
+
getCodexAccountSoftAvoidUntil(accountId, now) ?? 0,
|
|
1441
|
+
now + escalationMs,
|
|
1442
|
+
)
|
|
1443
|
+
: undefined;
|
|
1444
|
+
upstreamHealth.set(accountId, {
|
|
1445
|
+
...preservedCooldownFields(transientBase),
|
|
1446
|
+
consecutiveFailures,
|
|
1447
|
+
lastFailureStatus,
|
|
1448
|
+
lastFailureAt: now,
|
|
1449
|
+
...(hardCooldownUntil ? { cooldownUntil: hardCooldownUntil } : {}),
|
|
1450
|
+
...(softAvoidUntil !== undefined ? { softAvoidUntil } : {}),
|
|
1451
|
+
});
|
|
1452
|
+
// Drop this thread's pin immediately so the next continue can rebind without
|
|
1453
|
+
// waiting for the soft-avoid selectable check. Guard: only delete when the
|
|
1454
|
+
// thread is still pinned to the FAILING account — a late failure from account A
|
|
1455
|
+
// must not delete a newer healthy binding to account B (race: T→A, A fails,
|
|
1456
|
+
// T→B, late A failure must not delete B's mapping).
|
|
1457
|
+
if (failoverReady && meta.threadId) {
|
|
1458
|
+
deleteThreadAffinitiesForAccount(meta.threadId, accountId);
|
|
1459
|
+
}
|
|
1460
|
+
// Once the account is past the failover streak, clear every thread still pinned
|
|
1461
|
+
// to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
|
|
1462
|
+
if (shouldFailover(config, accountId, now)) {
|
|
1463
|
+
clearThreadAccountMapForAccount(accountId);
|
|
1464
|
+
}
|
|
1465
|
+
if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {
|
|
1469
|
+
if (!accountId) return providerName;
|
|
1470
|
+
// The main Codex login participates in rotation as "main-pool" (MAIN_CODEX_ACCOUNT_ID) but is the
|
|
1471
|
+
// same physical account as the "main" passthrough (null accountId). Log both under the base provider
|
|
1472
|
+
// name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`.
|
|
1473
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName;
|
|
1474
|
+
const account = (config.codexAccounts ?? [])
|
|
1475
|
+
.find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId);
|
|
1476
|
+
return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName;
|
|
1477
|
+
}
|