@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,1540 @@
|
|
|
1
|
+
import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
2
|
+
import { withCodexAccountLogLabel } from "./account-label";
|
|
3
|
+
import {
|
|
4
|
+
getCodexAccountCredential,
|
|
5
|
+
getValidCodexToken,
|
|
6
|
+
isCodexAccountGenerationLive,
|
|
7
|
+
markCodexAccountValidated,
|
|
8
|
+
readCodexAccountRecord,
|
|
9
|
+
saveCodexAccountCredential,
|
|
10
|
+
CodexCredentialGenerationConflictError,
|
|
11
|
+
CodexCredentialRefreshLockTimeoutError,
|
|
12
|
+
CodexCredentialRefreshBusyError,
|
|
13
|
+
CodexCredentialRefreshStaleError,
|
|
14
|
+
TokenRefreshError,
|
|
15
|
+
} from "./account-store";
|
|
16
|
+
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
|
|
17
|
+
import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
|
|
18
|
+
import {
|
|
19
|
+
clearCodexAccountCooldown,
|
|
20
|
+
clearThreadAccountMapForAccount,
|
|
21
|
+
getEffectiveActiveCodexAccountId,
|
|
22
|
+
reconcileCodexActiveAfterExclusion,
|
|
23
|
+
resetCodexRoutingForManualSelection,
|
|
24
|
+
} from "./routing";
|
|
25
|
+
import {
|
|
26
|
+
normalizeAccountPoolStickyLimit,
|
|
27
|
+
normalizeAccountPoolStrategy,
|
|
28
|
+
parseAccountPoolStickyLimit,
|
|
29
|
+
parseAccountPoolStrategy,
|
|
30
|
+
} from "./pool-rotation";
|
|
31
|
+
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
|
|
32
|
+
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
|
|
33
|
+
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
34
|
+
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
|
|
35
|
+
import {
|
|
36
|
+
clearAccountQuota,
|
|
37
|
+
getAccountQuota,
|
|
38
|
+
isCodexQuotaExhausted,
|
|
39
|
+
listAccountQuotas,
|
|
40
|
+
parseUsageQuota,
|
|
41
|
+
setAccountQuotaFromParsed,
|
|
42
|
+
updateAccountQuota,
|
|
43
|
+
type StoredAccountQuota,
|
|
44
|
+
type WhamUsageResponse,
|
|
45
|
+
} from "./quota";
|
|
46
|
+
export {
|
|
47
|
+
applyAccountQuotaFromUpstreamHeaders,
|
|
48
|
+
clearAccountQuota,
|
|
49
|
+
getAccountQuota,
|
|
50
|
+
parseUsageQuota,
|
|
51
|
+
setAccountQuotaFromParsed,
|
|
52
|
+
updateAccountQuota,
|
|
53
|
+
} from "./quota";
|
|
54
|
+
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
|
|
55
|
+
import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
56
|
+
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
57
|
+
import { reconcileLiveStateStores } from "../lib/state-store-registrations";
|
|
58
|
+
import {
|
|
59
|
+
clearMainAccountInfoCache,
|
|
60
|
+
getMainAccountInfoCache,
|
|
61
|
+
setMainAccountInfoCache,
|
|
62
|
+
type MainAccountInfo,
|
|
63
|
+
} from "./main-account-cache";
|
|
64
|
+
export { clearMainAccountInfoCache } from "./main-account-cache";
|
|
65
|
+
import { maskEmail } from "../lib/privacy";
|
|
66
|
+
import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
|
|
67
|
+
export { maskEmail } from "../lib/privacy";
|
|
68
|
+
import type { CodexAccount, OcxConfig } from "../types";
|
|
69
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
70
|
+
import { providerCodexAccountMode } from "../providers/registry";
|
|
71
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
72
|
+
import {
|
|
73
|
+
oauthAccountHealthFields,
|
|
74
|
+
projectCodexAccountHealth,
|
|
75
|
+
type OAuthAccountHealth,
|
|
76
|
+
type OAuthHealthLabel,
|
|
77
|
+
} from "../oauth/health";
|
|
78
|
+
import {
|
|
79
|
+
CODEX_ACCOUNT_ID_RE,
|
|
80
|
+
hasLegacyMainCodexPoolAccount,
|
|
81
|
+
isSelectableCodexPoolAccount,
|
|
82
|
+
isValidCodexAccountId,
|
|
83
|
+
} from "./account-id";
|
|
84
|
+
import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
|
|
85
|
+
import { ResourceAdmissionError } from "../lib/admission";
|
|
86
|
+
|
|
87
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
88
|
+
return new Response(JSON.stringify(data), {
|
|
89
|
+
status,
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
|
|
95
|
+
|
|
96
|
+
const MAX_CODEX_LOGIN_STATE_ROWS = 32;
|
|
97
|
+
const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
|
|
98
|
+
interface CodexLoginStateRow { status: string; startedAt: number; accountId?: string; email?: string; error?: string; doneAt?: number }
|
|
99
|
+
const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
|
|
100
|
+
export class CodexLoginStateBusyError extends ResourceAdmissionError {
|
|
101
|
+
constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
|
|
105
|
+
const row = codexAuthLoginState.get(flowId);
|
|
106
|
+
if (row) Object.assign(row, patch);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pruneCodexLoginState(now = Date.now()): void {
|
|
110
|
+
for (const [id, row] of codexAuthLoginState) {
|
|
111
|
+
if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
|
|
112
|
+
}
|
|
113
|
+
while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
114
|
+
const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
|
|
115
|
+
.sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
|
|
116
|
+
if (!terminal) break;
|
|
117
|
+
codexAuthLoginState.delete(terminal[0]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
|
|
122
|
+
if (!isValidCodexAccountId(accountId)) return null;
|
|
123
|
+
return (config.codexAccounts ?? [])
|
|
124
|
+
.find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function codexAccountPersistenceConflict(
|
|
128
|
+
config: OcxConfig,
|
|
129
|
+
accountId: string,
|
|
130
|
+
mode: "create" | "reauth",
|
|
131
|
+
): string | undefined {
|
|
132
|
+
if (mode === "reauth") {
|
|
133
|
+
return configuredPoolAccount(config, accountId)
|
|
134
|
+
? undefined
|
|
135
|
+
: "Pool account was removed while login was in progress. Add it again as a new account.";
|
|
136
|
+
}
|
|
137
|
+
const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId);
|
|
138
|
+
if (namespaceCollision) return namespaceCollision;
|
|
139
|
+
return (config.codexAccounts ?? []).some(account => account.id === accountId)
|
|
140
|
+
|| Boolean(getCodexAccountCredential(accountId))
|
|
141
|
+
? `Account id already exists: ${accountId}`
|
|
142
|
+
: undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean {
|
|
146
|
+
const normalized = plan?.trim().toLowerCase();
|
|
147
|
+
return normalized === "go" || normalized === "free";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
|
|
151
|
+
quota: T,
|
|
152
|
+
plan: string | null | undefined,
|
|
153
|
+
): T {
|
|
154
|
+
if (!quota || !isThirtyDayOnlyPlan(plan)) return quota;
|
|
155
|
+
return {
|
|
156
|
+
...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
|
|
157
|
+
...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
|
|
158
|
+
...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
|
|
159
|
+
...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
|
|
160
|
+
} as T;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function poolAccountDto(
|
|
164
|
+
account: CodexAccount,
|
|
165
|
+
quotaResult: PoolQuotaResult,
|
|
166
|
+
hasCredential: boolean,
|
|
167
|
+
paused: boolean,
|
|
168
|
+
): CodexAuthAccountDto {
|
|
169
|
+
const quota = quotaForPlan(quotaResult.quota, account.plan);
|
|
170
|
+
const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
|
|
171
|
+
const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
|
|
172
|
+
return {
|
|
173
|
+
id: account.id,
|
|
174
|
+
email: account.email,
|
|
175
|
+
...(account.alias !== undefined ? { alias: account.alias } : {}),
|
|
176
|
+
...(account.plan !== undefined ? { plan: account.plan } : {}),
|
|
177
|
+
...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
|
|
178
|
+
isMain: false,
|
|
179
|
+
paused,
|
|
180
|
+
quota: quota ? { ...quota } : null,
|
|
181
|
+
needsReauth,
|
|
182
|
+
hasCredential,
|
|
183
|
+
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
|
|
184
|
+
...oauthAccountHealthFields("codex", account.id, health),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveResetCreditAuth(
|
|
189
|
+
runtimeConfig: OcxConfig,
|
|
190
|
+
accountId: string,
|
|
191
|
+
): Promise<
|
|
192
|
+
| { ok: true; isMain: boolean; accessToken: string; chatgptAccountId: string }
|
|
193
|
+
| { ok: false; response: Response }
|
|
194
|
+
> {
|
|
195
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
196
|
+
if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
|
|
197
|
+
return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
|
|
198
|
+
}
|
|
199
|
+
const tokens = readCodexTokens();
|
|
200
|
+
if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
|
|
201
|
+
return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
|
|
202
|
+
}
|
|
203
|
+
if (!isValidCodexAccountId(accountId)) {
|
|
204
|
+
return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
|
|
205
|
+
}
|
|
206
|
+
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
207
|
+
return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
|
|
208
|
+
}
|
|
209
|
+
const cred = await getValidCodexToken(accountId);
|
|
210
|
+
return { ok: true, isMain: false, accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
|
|
214
|
+
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
215
|
+
const rawCredits = Array.isArray(obj.credits) ? obj.credits : [];
|
|
216
|
+
const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => {
|
|
217
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
218
|
+
const credit = raw as Record<string, unknown>;
|
|
219
|
+
return typeof credit.granted_at === "string" && typeof credit.expires_at === "string"
|
|
220
|
+
? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }]
|
|
221
|
+
: [];
|
|
222
|
+
});
|
|
223
|
+
const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count
|
|
224
|
+
?? obj.available_count;
|
|
225
|
+
return {
|
|
226
|
+
credits,
|
|
227
|
+
...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function safeResetCreditConsumeDto(input: unknown): { code: string } {
|
|
232
|
+
const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
|
|
233
|
+
return { code: typeof obj.code === "string" ? obj.code : "unknown" };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function isUnverifiedCodexImportEnabled(): boolean {
|
|
237
|
+
return process.env[MANUAL_IMPORT_ENV] === "1";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function manualImportDisabledResponse(): Response {
|
|
241
|
+
return jsonResponse({
|
|
242
|
+
error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
|
|
243
|
+
code: "manual_import_disabled",
|
|
244
|
+
}, 403);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function verifyCodexAccountWarmup(
|
|
248
|
+
accountId: string,
|
|
249
|
+
accessToken: string,
|
|
250
|
+
chatgptAccountId: string,
|
|
251
|
+
): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
|
|
252
|
+
try {
|
|
253
|
+
await warmCodexAccount({ accessToken, chatgptAccountId });
|
|
254
|
+
return { ok: true, validatedAt: Date.now() };
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const reason = codexWarmupFailureReason(err);
|
|
257
|
+
const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
response: jsonResponse({
|
|
261
|
+
error: upstream
|
|
262
|
+
? `Codex account warmup failed: ${upstream}`
|
|
263
|
+
: "Codex account warmup failed. Reauthenticate the account and try again.",
|
|
264
|
+
code: "codex_warmup_failed",
|
|
265
|
+
reason,
|
|
266
|
+
accountId,
|
|
267
|
+
}, 401),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
|
|
273
|
+
const ids = flowId
|
|
274
|
+
? [flowId]
|
|
275
|
+
: [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
|
|
276
|
+
for (const id of ids) {
|
|
277
|
+
let owner = codexAuthLoginState.get(id);
|
|
278
|
+
if (!owner) {
|
|
279
|
+
pruneCodexLoginState();
|
|
280
|
+
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
|
|
281
|
+
owner = { status: "error", startedAt: Date.now() };
|
|
282
|
+
codexAuthLoginState.set(id, owner);
|
|
283
|
+
}
|
|
284
|
+
Object.assign(owner, { status: "error", error, doneAt: Date.now() });
|
|
285
|
+
setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const MAIN_CACHE_TTL = 5 * 60_000;
|
|
290
|
+
const POOL_CACHE_TTL = 5 * 60_000;
|
|
291
|
+
const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
|
|
292
|
+
|
|
293
|
+
function nonEmptyPlan(value: unknown): string | null {
|
|
294
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isRuntimeConfig(config: OcxConfig): boolean {
|
|
298
|
+
return !!config && typeof config === "object" && !!config.providers;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function getRuntimeConfig(config: OcxConfig): OcxConfig {
|
|
302
|
+
return isRuntimeConfig(config) ? config : loadConfig();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
|
|
306
|
+
saveConfigPreservingClaudeCode(nextConfig);
|
|
307
|
+
if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
|
|
308
|
+
for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
|
|
309
|
+
delete sourceConfig[key];
|
|
310
|
+
}
|
|
311
|
+
Object.assign(sourceConfig, nextConfig);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function mapWithConcurrency<T, R>(
|
|
315
|
+
items: T[],
|
|
316
|
+
concurrency: number,
|
|
317
|
+
mapper: (item: T) => Promise<R>,
|
|
318
|
+
): Promise<R[]> {
|
|
319
|
+
const results = new Array<R>(items.length);
|
|
320
|
+
let next = 0;
|
|
321
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
322
|
+
while (next < items.length) {
|
|
323
|
+
const index = next++;
|
|
324
|
+
results[index] = await mapper(items[index]!);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
await Promise.all(workers);
|
|
328
|
+
return results;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const MAIN_TERMINAL_AUTH_CODES = new Set([
|
|
332
|
+
"invalid_workspace_selected",
|
|
333
|
+
"invalid_refresh_token",
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
|
|
337
|
+
if (resp.status === 401) return true;
|
|
338
|
+
if (resp.status !== 403) return false;
|
|
339
|
+
try {
|
|
340
|
+
const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
|
|
341
|
+
if (!body.displaySafe) return false;
|
|
342
|
+
const parsed = JSON.parse(body.text) as {
|
|
343
|
+
detail?: { code?: unknown } | string;
|
|
344
|
+
error?: { code?: unknown } | string;
|
|
345
|
+
code?: unknown;
|
|
346
|
+
};
|
|
347
|
+
const code = typeof parsed.detail === "object" && parsed.detail !== null
|
|
348
|
+
? parsed.detail.code
|
|
349
|
+
: typeof parsed.error === "object" && parsed.error !== null
|
|
350
|
+
? parsed.error.code
|
|
351
|
+
: parsed.code;
|
|
352
|
+
return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
|
|
353
|
+
} catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
interface MainAccountInfoFetchResult {
|
|
359
|
+
info: MainAccountInfo;
|
|
360
|
+
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
361
|
+
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
362
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
363
|
+
freshResetCredits?: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
|
|
367
|
+
const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
|
|
368
|
+
return info;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
|
|
372
|
+
|
|
373
|
+
async function retryMainAccountInfoIfIdentityChanged(
|
|
374
|
+
requestAccountId: string | null,
|
|
375
|
+
retriesRemaining: number,
|
|
376
|
+
): Promise<MainAccountInfoFetchResult | null> {
|
|
377
|
+
const currentAccountId = getMainChatgptAccountId();
|
|
378
|
+
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
|
|
379
|
+
reconcileMainCodexAccountRuntimeState();
|
|
380
|
+
return retriesRemaining > 0
|
|
381
|
+
? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
|
|
382
|
+
: { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
|
|
386
|
+
const writerGeneration = captureConfigGeneration();
|
|
387
|
+
reconcileMainCodexAccountRuntimeState();
|
|
388
|
+
const tokenRead = readCodexTokensResult();
|
|
389
|
+
if (tokenRead.status !== "ok") {
|
|
390
|
+
// A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
|
|
391
|
+
// gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
|
|
392
|
+
// account for reauth here destroyed healthy email/plan/quota state and pinned a working
|
|
393
|
+
// account as unusable. Preserve what we already know and let the caller retry; request
|
|
394
|
+
// routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
|
|
395
|
+
// account DTO still reports hasCredential=false while the file is unreadable.
|
|
396
|
+
const preserved = getMainAccountInfoCache();
|
|
397
|
+
return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO };
|
|
398
|
+
}
|
|
399
|
+
const tokens = tokenRead.tokens;
|
|
400
|
+
const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
|
|
401
|
+
const cached = getMainAccountInfoCache();
|
|
402
|
+
if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
|
|
403
|
+
return { info: cached };
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
407
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
408
|
+
signal: AbortSignal.timeout(8000),
|
|
409
|
+
});
|
|
410
|
+
if (!resp.ok) {
|
|
411
|
+
const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
|
|
412
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
413
|
+
if (retried) return retried;
|
|
414
|
+
if (terminalAuthFailure) {
|
|
415
|
+
clearMainAccountInfoCache();
|
|
416
|
+
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
|
|
417
|
+
}
|
|
418
|
+
return { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
419
|
+
}
|
|
420
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
421
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
422
|
+
if (retried) return retried;
|
|
423
|
+
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
|
|
424
|
+
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
|
|
425
|
+
const freshResetCredits = quota?.resetCredits;
|
|
426
|
+
const result = {
|
|
427
|
+
email: data.email ?? null,
|
|
428
|
+
plan,
|
|
429
|
+
quota,
|
|
430
|
+
ts: Date.now(),
|
|
431
|
+
};
|
|
432
|
+
setMainAccountInfoCache(result);
|
|
433
|
+
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
434
|
+
// Mirror main quota + plan into the shared stores so the rotation engine can
|
|
435
|
+
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
436
|
+
setMainAccountPlan(result.plan);
|
|
437
|
+
if (result.quota) {
|
|
438
|
+
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
info: result,
|
|
442
|
+
...(quota ? { freshQuota: quota } : {}),
|
|
443
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
444
|
+
};
|
|
445
|
+
} catch {
|
|
446
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
|
|
447
|
+
return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
interface PoolQuotaResult {
|
|
452
|
+
quota: StoredAccountQuota | null;
|
|
453
|
+
needsReauth: boolean;
|
|
454
|
+
/** Credential generation whose cache or network result this DTO state belongs to. */
|
|
455
|
+
credentialGeneration?: number;
|
|
456
|
+
/** Present only when this call freshly parsed a WHAM usage response. */
|
|
457
|
+
freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
|
|
458
|
+
/** Present only when this call's WHAM response included a non-empty `plan_type`. */
|
|
459
|
+
freshPlan?: string;
|
|
460
|
+
/** Credential generation used by this fresh quota request. */
|
|
461
|
+
freshCredentialGeneration?: number;
|
|
462
|
+
/** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
|
|
463
|
+
freshResetCredits?: number;
|
|
464
|
+
quotaProbeSkipped?: true;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
interface PoolQuotaRefreshFlight {
|
|
468
|
+
state: {
|
|
469
|
+
startCredentialGeneration?: number;
|
|
470
|
+
resolvedCredentialGeneration?: number;
|
|
471
|
+
};
|
|
472
|
+
promise: Promise<PoolQuotaResult>;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
|
|
476
|
+
const MAX_POOL_QUOTA_FLIGHTS = 16;
|
|
477
|
+
|
|
478
|
+
export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
|
|
479
|
+
constructor() {
|
|
480
|
+
super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
|
|
481
|
+
this.name = "PoolQuotaProbeBusyError";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function poolQuotaFlightCount(): number {
|
|
486
|
+
let count = 0;
|
|
487
|
+
for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
|
|
488
|
+
return count;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
|
|
492
|
+
export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
|
|
493
|
+
const prefix = `admission-test-${crypto.randomUUID()}`;
|
|
494
|
+
for (let index = 0; index < (options.loginFlows ?? 0); index++) {
|
|
495
|
+
codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
|
|
496
|
+
}
|
|
497
|
+
for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
|
|
498
|
+
poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
|
|
499
|
+
state: {},
|
|
500
|
+
promise: new Promise<PoolQuotaResult>(() => {}),
|
|
501
|
+
}]));
|
|
502
|
+
}
|
|
503
|
+
return () => {
|
|
504
|
+
for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
|
|
505
|
+
for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export interface CodexAuthAccountDto {
|
|
510
|
+
id: string;
|
|
511
|
+
alias?: string;
|
|
512
|
+
email: string;
|
|
513
|
+
plan?: string | null;
|
|
514
|
+
logLabel?: string;
|
|
515
|
+
isMain: boolean;
|
|
516
|
+
paused: boolean;
|
|
517
|
+
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
518
|
+
needsReauth?: boolean;
|
|
519
|
+
hasCredential: boolean;
|
|
520
|
+
health: OAuthAccountHealth;
|
|
521
|
+
healthLabel: OAuthHealthLabel;
|
|
522
|
+
healthSummary: string;
|
|
523
|
+
healthAction?: string;
|
|
524
|
+
quotaProbeSkipped?: true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
interface FreshPoolPlanUpdate {
|
|
528
|
+
accountId: string;
|
|
529
|
+
plan: string;
|
|
530
|
+
credentialGeneration: number;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
|
|
535
|
+
* the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
|
|
536
|
+
* WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
|
|
537
|
+
* recreate a deleted config from the server's older in-memory snapshot.
|
|
538
|
+
*/
|
|
539
|
+
function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
|
|
540
|
+
if (updates.length === 0) return;
|
|
541
|
+
let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
|
|
542
|
+
try {
|
|
543
|
+
outcome = mutatePersistedConfig(persistedConfig => {
|
|
544
|
+
const accepted: FreshPoolPlanUpdate[] = [];
|
|
545
|
+
let changed = false;
|
|
546
|
+
for (const update of updates) {
|
|
547
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
548
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
549
|
+
const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
|
|
550
|
+
if (!liveAccount || !persistedAccount) continue;
|
|
551
|
+
accepted.push(update);
|
|
552
|
+
if (persistedAccount.plan !== update.plan) {
|
|
553
|
+
persistedAccount.plan = update.plan;
|
|
554
|
+
changed = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return { changed, value: accepted };
|
|
558
|
+
});
|
|
559
|
+
} catch (error) {
|
|
560
|
+
// Plan persistence is derived metadata on a read route. Contention must fail closed without
|
|
561
|
+
// turning account listing into a 500; a later refresh can retry against the latest files.
|
|
562
|
+
if (error instanceof ConfigMutationLockError) return;
|
|
563
|
+
throw error;
|
|
564
|
+
}
|
|
565
|
+
if (outcome.status === "unavailable") return;
|
|
566
|
+
for (const update of outcome.value) {
|
|
567
|
+
// A replacement immediately after the durable commit is allowed to supersede the result, but
|
|
568
|
+
// the long-lived object must never be updated from that stale generation.
|
|
569
|
+
if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
|
|
570
|
+
const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
|
|
571
|
+
if (liveAccount) {
|
|
572
|
+
liveAccount.plan = update.plan;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function fetchFreshPoolAccountQuota(
|
|
578
|
+
accountId: string,
|
|
579
|
+
existing: StoredAccountQuota | null,
|
|
580
|
+
configuredPlan?: string,
|
|
581
|
+
onCredentialGeneration?: (generation: number) => void,
|
|
582
|
+
): Promise<PoolQuotaResult> {
|
|
583
|
+
const writerGeneration = captureConfigGeneration();
|
|
584
|
+
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
|
|
585
|
+
try {
|
|
586
|
+
const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
|
|
587
|
+
requestCredentialGeneration = generation;
|
|
588
|
+
onCredentialGeneration?.(generation);
|
|
589
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
590
|
+
headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
|
|
591
|
+
signal: AbortSignal.timeout(8000),
|
|
592
|
+
});
|
|
593
|
+
if (!resp.ok) {
|
|
594
|
+
return {
|
|
595
|
+
quota: existing ?? null,
|
|
596
|
+
needsReauth: resp.status === 401,
|
|
597
|
+
credentialGeneration: generation,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
601
|
+
const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
602
|
+
const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
|
|
603
|
+
const freshResetCredits = quota?.resetCredits;
|
|
604
|
+
if (!quota) {
|
|
605
|
+
return {
|
|
606
|
+
quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
|
|
607
|
+
needsReauth: false,
|
|
608
|
+
credentialGeneration: generation,
|
|
609
|
+
...(freshPlan !== undefined
|
|
610
|
+
? { freshPlan, freshCredentialGeneration: generation }
|
|
611
|
+
: {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (!isCodexAccountGenerationLive(accountId, generation)) {
|
|
615
|
+
return { quota: null, needsReauth: false, credentialGeneration: generation };
|
|
616
|
+
}
|
|
617
|
+
setAccountQuotaFromParsed(accountId, quota, writerGeneration);
|
|
618
|
+
return {
|
|
619
|
+
quota: getAccountQuota(accountId),
|
|
620
|
+
needsReauth: false,
|
|
621
|
+
credentialGeneration: generation,
|
|
622
|
+
freshQuota: quota,
|
|
623
|
+
freshCredentialGeneration: generation,
|
|
624
|
+
...(freshPlan !== undefined ? { freshPlan } : {}),
|
|
625
|
+
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
626
|
+
};
|
|
627
|
+
} catch (e) {
|
|
628
|
+
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
|
|
629
|
+
|| e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
630
|
+
return {
|
|
631
|
+
quota: existing ?? null,
|
|
632
|
+
needsReauth: false,
|
|
633
|
+
credentialGeneration: requestCredentialGeneration,
|
|
634
|
+
...(e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError
|
|
635
|
+
? { quotaProbeSkipped: true as const }
|
|
636
|
+
: {}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (e instanceof TokenRefreshError) {
|
|
640
|
+
return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
|
|
641
|
+
}
|
|
642
|
+
return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
|
|
647
|
+
const existing = getAccountQuota(accountId);
|
|
648
|
+
if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
|
|
649
|
+
return {
|
|
650
|
+
quota: existing,
|
|
651
|
+
needsReauth: false,
|
|
652
|
+
credentialGeneration: readCodexAccountRecord(accountId)?.generation,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// A token refresh may increment the generation (and rotate the refresh token) before WHAM
|
|
656
|
+
// completes. Join a flight whose starting or resolved generation is still current, but let a
|
|
657
|
+
// replacement credential with the same pool id start its own request.
|
|
658
|
+
const record = readCodexAccountRecord(accountId);
|
|
659
|
+
const flights = poolQuotaRefreshInFlight.get(accountId);
|
|
660
|
+
const current = flights && [...flights].find(flight => {
|
|
661
|
+
const generation = flight.state.resolvedCredentialGeneration
|
|
662
|
+
?? flight.state.startCredentialGeneration;
|
|
663
|
+
return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
|
|
664
|
+
});
|
|
665
|
+
if (current) return current.promise;
|
|
666
|
+
if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
|
|
667
|
+
|
|
668
|
+
const state: PoolQuotaRefreshFlight["state"] = {
|
|
669
|
+
startCredentialGeneration: record?.generation,
|
|
670
|
+
};
|
|
671
|
+
const refresh = fetchFreshPoolAccountQuota(
|
|
672
|
+
accountId,
|
|
673
|
+
existing,
|
|
674
|
+
configuredPlan,
|
|
675
|
+
generation => { state.resolvedCredentialGeneration = generation; },
|
|
676
|
+
);
|
|
677
|
+
const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
|
|
678
|
+
const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
|
|
679
|
+
activeFlights.add(flight);
|
|
680
|
+
if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
|
|
681
|
+
try {
|
|
682
|
+
return await refresh;
|
|
683
|
+
} finally {
|
|
684
|
+
activeFlights.delete(flight);
|
|
685
|
+
if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
|
|
686
|
+
poolQuotaRefreshInFlight.delete(accountId);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let primeInFlight: Promise<void> | null = null;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Best-effort prime of pool-account (and main) quota so the rotation engine has
|
|
695
|
+
* real usage scores instead of leaving every account at the unknown sentinel.
|
|
696
|
+
*
|
|
697
|
+
* Quota is otherwise populated only from live upstream headers (an idle pool
|
|
698
|
+
* account never serves traffic, so it never gets scored) or from the dashboard
|
|
699
|
+
* WHAM fetch (a CLI-only user never opens it). Without priming, every account
|
|
700
|
+
* stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
|
|
701
|
+
* and lazily before routing when the active account is unknown.
|
|
702
|
+
*
|
|
703
|
+
* Single-flight: concurrent callers share one pass instead of stampeding N WHAM
|
|
704
|
+
* fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
|
|
705
|
+
* cost, so the worst case is one WHAM call per account per TTL window. Failures
|
|
706
|
+
* are swallowed: a blocked WSL network must never crash startup or a request.
|
|
707
|
+
*/
|
|
708
|
+
export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): Promise<void> {
|
|
709
|
+
const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
710
|
+
if (
|
|
711
|
+
!openai
|
|
712
|
+
|| openai.disabled === true
|
|
713
|
+
|| !isCanonicalOpenAiForwardProvider(openai)
|
|
714
|
+
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
|
|
715
|
+
) return;
|
|
716
|
+
if (primeInFlight) return primeInFlight;
|
|
717
|
+
// Seed the observed physical main identity before startup/lazy priming can populate quota or
|
|
718
|
+
// plan state. Otherwise the first post-startup account switch sees no previous identity and
|
|
719
|
+
// skips the purge that protects the stable __main__ alias.
|
|
720
|
+
reconcileMainCodexAccountRuntimeState();
|
|
721
|
+
primeInFlight = (async () => {
|
|
722
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
723
|
+
const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
724
|
+
const stale = pool.filter(a => {
|
|
725
|
+
const q = getAccountQuota(a.id);
|
|
726
|
+
return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
|
|
727
|
+
});
|
|
728
|
+
const primeMain = !!readCodexTokens() && !getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
|
|
729
|
+
try {
|
|
730
|
+
await Promise.allSettled([
|
|
731
|
+
primeMain ? fetchMainAccountInfo(false) : Promise.resolve(),
|
|
732
|
+
mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
|
|
733
|
+
if (!getCodexAccountCredential(a.id)) return;
|
|
734
|
+
await fetchPoolAccountQuota(a.id, false, a.plan);
|
|
735
|
+
}),
|
|
736
|
+
]);
|
|
737
|
+
} catch {
|
|
738
|
+
// Priming is best-effort; never propagate.
|
|
739
|
+
}
|
|
740
|
+
if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
|
|
741
|
+
console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
|
|
742
|
+
}
|
|
743
|
+
})().finally(() => { primeInFlight = null; });
|
|
744
|
+
return primeInFlight;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Test-only: drop any in-flight prime pass so a leaked single-flight promise
|
|
748
|
+
* from another suite cannot coalesce into the next prime. */
|
|
749
|
+
export function clearCodexQuotaPrimeState(): void {
|
|
750
|
+
primeInFlight = null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
|
|
754
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
755
|
+
const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
|
|
756
|
+
const mainInfo = await fetchMainAccountInfo(forceRefresh);
|
|
757
|
+
const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
758
|
+
const cred = getCodexAccountCredential(account.id);
|
|
759
|
+
let quotaResult: PoolQuotaResult;
|
|
760
|
+
if (!cred) {
|
|
761
|
+
quotaResult = { quota: null, needsReauth: true };
|
|
762
|
+
} else {
|
|
763
|
+
try {
|
|
764
|
+
quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
|
|
767
|
+
quotaResult = {
|
|
768
|
+
quota: getAccountQuota(account.id),
|
|
769
|
+
needsReauth: false,
|
|
770
|
+
credentialGeneration: readCodexAccountRecord(account.id)?.generation,
|
|
771
|
+
quotaProbeSkipped: true,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return { accountId: account.id, quotaResult };
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
|
|
779
|
+
// changes after every parallel read settles, then apply one narrow disk patch for the batch.
|
|
780
|
+
const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
|
|
781
|
+
const plan = quotaResult.freshPlan;
|
|
782
|
+
const credentialGeneration = quotaResult.freshCredentialGeneration;
|
|
783
|
+
return plan && credentialGeneration !== undefined
|
|
784
|
+
? [{ accountId, plan, credentialGeneration }]
|
|
785
|
+
: [];
|
|
786
|
+
});
|
|
787
|
+
reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
|
|
788
|
+
|
|
789
|
+
const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
|
|
790
|
+
const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
|
|
791
|
+
if (!currentAccount) return [];
|
|
792
|
+
const currentCredential = getCodexAccountCredential(accountId);
|
|
793
|
+
if (!currentCredential) {
|
|
794
|
+
return [poolAccountDto(
|
|
795
|
+
currentAccount,
|
|
796
|
+
{ quota: null, needsReauth: true },
|
|
797
|
+
false,
|
|
798
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
799
|
+
)];
|
|
800
|
+
}
|
|
801
|
+
const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
|
|
802
|
+
const generationLive = resultGeneration === undefined
|
|
803
|
+
|| isCodexAccountGenerationLive(accountId, resultGeneration);
|
|
804
|
+
const effectiveQuotaResult = !generationLive
|
|
805
|
+
? { quota: null, needsReauth: false }
|
|
806
|
+
: quotaResult;
|
|
807
|
+
// Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
|
|
808
|
+
// missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
|
|
809
|
+
const dtoAccount = generationLive && quotaResult.freshPlan
|
|
810
|
+
? { ...currentAccount, plan: quotaResult.freshPlan }
|
|
811
|
+
: currentAccount;
|
|
812
|
+
return [poolAccountDto(
|
|
813
|
+
dtoAccount,
|
|
814
|
+
effectiveQuotaResult,
|
|
815
|
+
true,
|
|
816
|
+
isCodexAccountPaused(runtimeConfig, accountId),
|
|
817
|
+
)];
|
|
818
|
+
});
|
|
819
|
+
const hasMainCredential = readCodexTokens() !== null;
|
|
820
|
+
const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
821
|
+
const mainHealth = projectCodexAccountHealth({
|
|
822
|
+
accountId: MAIN_CODEX_ACCOUNT_ID,
|
|
823
|
+
needsReauth: mainNeedsReauth,
|
|
824
|
+
});
|
|
825
|
+
const main: CodexAuthAccountDto = {
|
|
826
|
+
id: MAIN_CODEX_ACCOUNT_ID,
|
|
827
|
+
email: mainInfo.email ?? "Codex App login",
|
|
828
|
+
plan: mainInfo.plan,
|
|
829
|
+
isMain: true,
|
|
830
|
+
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
831
|
+
hasCredential: hasMainCredential,
|
|
832
|
+
needsReauth: mainNeedsReauth,
|
|
833
|
+
quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
|
|
834
|
+
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
|
|
835
|
+
};
|
|
836
|
+
return [main, ...withQuota];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
interface PauseExhaustedResult {
|
|
840
|
+
pausedAccountIds: string[];
|
|
841
|
+
checkedAccountCount: number;
|
|
842
|
+
failedAccountCount: number;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
|
|
846
|
+
reconcileCodexActiveAfterExclusion(config, pausedActiveId);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise<PauseExhaustedResult> {
|
|
850
|
+
const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
|
|
851
|
+
const mainAttempted = readCodexTokens() !== null;
|
|
852
|
+
const [mainResult, poolResults] = await Promise.all([
|
|
853
|
+
fetchMainAccountInfoAttempt(true, 1),
|
|
854
|
+
mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
|
|
855
|
+
if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
|
|
856
|
+
return {
|
|
857
|
+
account,
|
|
858
|
+
quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
|
|
859
|
+
};
|
|
860
|
+
}),
|
|
861
|
+
]);
|
|
862
|
+
|
|
863
|
+
let checkedAccountCount = 0;
|
|
864
|
+
let failedAccountCount = 0;
|
|
865
|
+
const exhaustedIds: string[] = [];
|
|
866
|
+
if (mainAttempted) {
|
|
867
|
+
if (mainResult.freshQuota && mainResult.info.plan) {
|
|
868
|
+
checkedAccountCount += 1;
|
|
869
|
+
if (
|
|
870
|
+
!isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
|
|
871
|
+
&& isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan)
|
|
872
|
+
) {
|
|
873
|
+
exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID);
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
failedAccountCount += 1;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
for (const { account, quotaResult } of poolResults) {
|
|
880
|
+
const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
|
|
881
|
+
if (!currentAccount) continue;
|
|
882
|
+
const generation = quotaResult?.freshCredentialGeneration;
|
|
883
|
+
const plan = quotaResult?.freshPlan ?? currentAccount.plan;
|
|
884
|
+
if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
|
|
885
|
+
failedAccountCount += 1;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
checkedAccountCount += 1;
|
|
889
|
+
if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
|
|
890
|
+
exhaustedIds.push(account.id);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
for (const id of exhaustedIds) {
|
|
895
|
+
setCodexAccountPaused(config, id, true);
|
|
896
|
+
clearThreadAccountMapForAccount(id);
|
|
897
|
+
}
|
|
898
|
+
for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
|
|
899
|
+
return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export async function handleCodexAuthAPI(
|
|
903
|
+
req: Request,
|
|
904
|
+
url: URL,
|
|
905
|
+
config: OcxConfig,
|
|
906
|
+
): Promise<Response | null> {
|
|
907
|
+
|
|
908
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
|
|
909
|
+
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
910
|
+
return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
|
|
914
|
+
if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse();
|
|
915
|
+
|
|
916
|
+
let body: { id: string; email: string; plan?: string; accessToken: string; refreshToken: string; chatgptAccountId: string };
|
|
917
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
918
|
+
if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) {
|
|
919
|
+
return jsonResponse({ error: "Missing required fields" }, 400);
|
|
920
|
+
}
|
|
921
|
+
if (!isValidCodexAccountId(body.id)) {
|
|
922
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
923
|
+
}
|
|
924
|
+
if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) {
|
|
925
|
+
return jsonResponse({ error: "Input too large" }, 400);
|
|
926
|
+
}
|
|
927
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
928
|
+
const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create");
|
|
929
|
+
if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
|
|
930
|
+
// 1.1: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
931
|
+
const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
|
|
932
|
+
const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan);
|
|
933
|
+
if (collision.collision) {
|
|
934
|
+
return jsonResponse({ error: collision.reason }, 400);
|
|
935
|
+
}
|
|
936
|
+
// 4.2: use JWT exp for expiresAt instead of hardcoded 1 hour
|
|
937
|
+
const payload = decodeJwtPayload(body.accessToken);
|
|
938
|
+
const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
|
|
939
|
+
const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId);
|
|
940
|
+
if (!warmup.ok) return warmup.response;
|
|
941
|
+
const latestConfig = getRuntimeConfig(config);
|
|
942
|
+
const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
|
|
943
|
+
if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
|
|
944
|
+
saveCodexAccountCredential(body.id, {
|
|
945
|
+
accessToken: body.accessToken,
|
|
946
|
+
refreshToken: body.refreshToken,
|
|
947
|
+
expiresAt: exp,
|
|
948
|
+
chatgptAccountId: derivedAccountId,
|
|
949
|
+
});
|
|
950
|
+
markCodexAccountValidated(body.id, warmup.validatedAt);
|
|
951
|
+
clearAccountNeedsReauth(body.id);
|
|
952
|
+
const accounts = latestConfig.codexAccounts ?? [];
|
|
953
|
+
accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
|
|
954
|
+
latestConfig.codexAccounts = accounts;
|
|
955
|
+
saveRuntimeConfig(config, latestConfig);
|
|
956
|
+
reconcileLiveStateStores();
|
|
957
|
+
return jsonResponse({ ok: true });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
|
|
961
|
+
const id = url.searchParams.get("id");
|
|
962
|
+
if (!id) return jsonResponse({ error: "Missing id" }, 400);
|
|
963
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
964
|
+
const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id)
|
|
965
|
+
&& (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id);
|
|
966
|
+
if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
|
|
967
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
968
|
+
}
|
|
969
|
+
deleteCodexAccount(runtimeConfig, id);
|
|
970
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
971
|
+
reconcileLiveStateStores();
|
|
972
|
+
return jsonResponse({ ok: true });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
|
|
976
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
|
|
977
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
978
|
+
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
979
|
+
if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
|
|
980
|
+
if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
981
|
+
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
982
|
+
return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
|
|
983
|
+
}
|
|
984
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
985
|
+
const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
|
|
986
|
+
if (!account) return jsonResponse({ error: "Account not found" }, 404);
|
|
987
|
+
if (alias) account.alias = alias;
|
|
988
|
+
else delete account.alias;
|
|
989
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
990
|
+
return jsonResponse({ ok: true, id, alias: alias || null });
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") {
|
|
994
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown };
|
|
995
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
996
|
+
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
|
|
997
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
998
|
+
}
|
|
999
|
+
if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
|
|
1000
|
+
|
|
1001
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1002
|
+
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
1003
|
+
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
1004
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
1005
|
+
|
|
1006
|
+
setCodexAccountPaused(runtimeConfig, id, body.paused);
|
|
1007
|
+
if (body.paused) {
|
|
1008
|
+
clearThreadAccountMapForAccount(id);
|
|
1009
|
+
selectFallbackAfterPause(runtimeConfig, id);
|
|
1010
|
+
}
|
|
1011
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1012
|
+
return jsonResponse({
|
|
1013
|
+
ok: true,
|
|
1014
|
+
id,
|
|
1015
|
+
paused: body.paused,
|
|
1016
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1017
|
+
appliesImmediately: true,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
|
|
1022
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1023
|
+
const result = await pauseExhaustedCodexAccounts(runtimeConfig);
|
|
1024
|
+
const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
|
|
1025
|
+
if (checkedAccountCount === 0 && failedAccountCount > 0) {
|
|
1026
|
+
return jsonResponse({
|
|
1027
|
+
ok: false,
|
|
1028
|
+
error: "Failed to refresh any Codex account quota",
|
|
1029
|
+
checkedAccountCount,
|
|
1030
|
+
failedAccountCount,
|
|
1031
|
+
}, 502);
|
|
1032
|
+
}
|
|
1033
|
+
if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig);
|
|
1034
|
+
return jsonResponse({
|
|
1035
|
+
ok: true,
|
|
1036
|
+
pausedAccountIds,
|
|
1037
|
+
pausedCount: pausedAccountIds.length,
|
|
1038
|
+
checkedAccountCount,
|
|
1039
|
+
failedAccountCount,
|
|
1040
|
+
complete: failedAccountCount === 0,
|
|
1041
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1042
|
+
appliesImmediately: true,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
|
|
1047
|
+
// model path for Codex Desktop, so a cooldown that outlives the real upstream limit
|
|
1048
|
+
// otherwise leaves editing config.toml as the user's only recovery.
|
|
1049
|
+
//
|
|
1050
|
+
// Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
|
|
1051
|
+
// exactly like an account that simply had no live cooldown, so this route cannot be used
|
|
1052
|
+
// to enumerate configured accounts. Cooldown state is runtime-only and independent of the
|
|
1053
|
+
// account list, so 404 would carry no useful meaning anyway.
|
|
1054
|
+
if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
|
|
1055
|
+
const body = await req.json().catch(() => ({})) as { id?: unknown };
|
|
1056
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1057
|
+
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
|
|
1058
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1059
|
+
}
|
|
1060
|
+
return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
|
|
1064
|
+
let body: { accountId: string | null };
|
|
1065
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1066
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1067
|
+
const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID;
|
|
1068
|
+
if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
|
|
1069
|
+
return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409);
|
|
1070
|
+
}
|
|
1071
|
+
if (isCodexAccountPaused(runtimeConfig, targetAccountId)) {
|
|
1072
|
+
return jsonResponse({ error: "Account is paused" }, 409);
|
|
1073
|
+
}
|
|
1074
|
+
if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
|
|
1075
|
+
if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1076
|
+
const exists = (runtimeConfig.codexAccounts ?? [])
|
|
1077
|
+
.some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
|
|
1078
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 400);
|
|
1079
|
+
}
|
|
1080
|
+
runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
|
|
1081
|
+
resetCodexRoutingForManualSelection(targetAccountId);
|
|
1082
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1083
|
+
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
|
|
1087
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1088
|
+
return jsonResponse({
|
|
1089
|
+
activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
|
|
1090
|
+
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
|
|
1091
|
+
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
|
|
1092
|
+
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
1093
|
+
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
|
|
1098
|
+
let body: { threshold: number };
|
|
1099
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1100
|
+
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
|
|
1101
|
+
return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
|
|
1102
|
+
}
|
|
1103
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1104
|
+
runtimeConfig.autoSwitchThreshold = body.threshold;
|
|
1105
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1106
|
+
return jsonResponse({ ok: true });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (
|
|
1110
|
+
url.pathname === "/api/codex-auth/pool-strategy"
|
|
1111
|
+
&& (req.method === "PUT" || req.method === "PATCH")
|
|
1112
|
+
) {
|
|
1113
|
+
let parsedBody: unknown;
|
|
1114
|
+
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1115
|
+
if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
|
|
1116
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
1117
|
+
}
|
|
1118
|
+
const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown };
|
|
1119
|
+
if (body.strategy === undefined && body.stickyLimit === undefined) {
|
|
1120
|
+
return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
|
|
1121
|
+
}
|
|
1122
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1123
|
+
let nextStrategy: NonNullable<ReturnType<typeof parseAccountPoolStrategy>> | undefined;
|
|
1124
|
+
let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
|
|
1125
|
+
if (body.strategy !== undefined) {
|
|
1126
|
+
const parsed = parseAccountPoolStrategy(body.strategy);
|
|
1127
|
+
if (parsed === null) {
|
|
1128
|
+
return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400);
|
|
1129
|
+
}
|
|
1130
|
+
nextStrategy = parsed;
|
|
1131
|
+
}
|
|
1132
|
+
if (body.stickyLimit !== undefined) {
|
|
1133
|
+
const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
|
|
1134
|
+
if (parsed === null) {
|
|
1135
|
+
return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
|
|
1136
|
+
}
|
|
1137
|
+
nextSticky = parsed;
|
|
1138
|
+
}
|
|
1139
|
+
if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy;
|
|
1140
|
+
if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky;
|
|
1141
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1142
|
+
return jsonResponse({
|
|
1143
|
+
ok: true,
|
|
1144
|
+
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
|
|
1145
|
+
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
|
|
1150
|
+
let body: { threshold: number };
|
|
1151
|
+
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
|
|
1152
|
+
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
|
|
1153
|
+
return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
|
|
1154
|
+
}
|
|
1155
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1156
|
+
runtimeConfig.upstreamFailoverThreshold = body.threshold;
|
|
1157
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
1158
|
+
return jsonResponse({ ok: true });
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
|
|
1162
|
+
const quotas: Record<string, unknown> = {};
|
|
1163
|
+
for (const [id, q] of listAccountQuotas()) quotas[id] = q;
|
|
1164
|
+
return jsonResponse({ quotas });
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") {
|
|
1168
|
+
const accountId = url.searchParams.get("accountId");
|
|
1169
|
+
if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
const auth = await resolveResetCreditAuth(getRuntimeConfig(config), accountId);
|
|
1173
|
+
if (!auth.ok) return auth.response;
|
|
1174
|
+
|
|
1175
|
+
const resp = await fetch(
|
|
1176
|
+
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
|
|
1177
|
+
{
|
|
1178
|
+
headers: {
|
|
1179
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
1180
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
1181
|
+
},
|
|
1182
|
+
signal: AbortSignal.timeout(8000),
|
|
1183
|
+
},
|
|
1184
|
+
);
|
|
1185
|
+
if (!resp.ok) {
|
|
1186
|
+
await resp.body?.cancel().catch(() => {});
|
|
1187
|
+
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
1188
|
+
}
|
|
1189
|
+
return jsonResponse(safeResetCreditsDto(await resp.json()));
|
|
1190
|
+
} catch (e) {
|
|
1191
|
+
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
|
|
1196
|
+
const body = (await req.json().catch(() => ({}))) as { accountId?: string };
|
|
1197
|
+
if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
|
|
1198
|
+
|
|
1199
|
+
try {
|
|
1200
|
+
const auth = await resolveResetCreditAuth(getRuntimeConfig(config), body.accountId);
|
|
1201
|
+
if (!auth.ok) return auth.response;
|
|
1202
|
+
|
|
1203
|
+
const idempotencyKey = crypto.randomUUID();
|
|
1204
|
+
const resp = await fetch(
|
|
1205
|
+
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
|
|
1206
|
+
{
|
|
1207
|
+
method: "POST",
|
|
1208
|
+
headers: {
|
|
1209
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
1210
|
+
"ChatGPT-Account-Id": auth.chatgptAccountId,
|
|
1211
|
+
"Content-Type": "application/json",
|
|
1212
|
+
},
|
|
1213
|
+
body: JSON.stringify({ redeem_request_id: idempotencyKey }),
|
|
1214
|
+
signal: AbortSignal.timeout(10_000),
|
|
1215
|
+
},
|
|
1216
|
+
);
|
|
1217
|
+
if (!resp.ok) {
|
|
1218
|
+
await resp.body?.cancel().catch(() => {});
|
|
1219
|
+
return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
|
|
1220
|
+
}
|
|
1221
|
+
const result = safeResetCreditConsumeDto(await resp.json());
|
|
1222
|
+
// After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
|
|
1223
|
+
// and return remaining only when that refresh freshly parsed available_count.
|
|
1224
|
+
// Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
|
|
1225
|
+
if (result.code === "reset" || result.code === "already_redeemed") {
|
|
1226
|
+
let freshResetCredits: number | undefined;
|
|
1227
|
+
if (auth.isMain) {
|
|
1228
|
+
({ freshResetCredits } = await fetchMainAccountInfoAttempt(true, 1));
|
|
1229
|
+
} else {
|
|
1230
|
+
const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
|
|
1231
|
+
({ freshResetCredits } = await fetchPoolAccountQuota(body.accountId, true, account?.plan));
|
|
1232
|
+
}
|
|
1233
|
+
return jsonResponse({
|
|
1234
|
+
code: result.code,
|
|
1235
|
+
...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
|
|
1236
|
+
? { remaining: freshResetCredits }
|
|
1237
|
+
: {}),
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
return jsonResponse(result);
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
if (e instanceof PoolQuotaProbeBusyError) {
|
|
1243
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
1244
|
+
response.headers.set("Retry-After", "1");
|
|
1245
|
+
return response;
|
|
1246
|
+
}
|
|
1247
|
+
return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
|
|
1252
|
+
const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
|
|
1253
|
+
const requestedAccountId = body.id?.trim();
|
|
1254
|
+
const reauth = body.reauth === true;
|
|
1255
|
+
if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
|
|
1256
|
+
return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
1257
|
+
}
|
|
1258
|
+
const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
|
|
1259
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
1260
|
+
const preflightConflict = !reauth
|
|
1261
|
+
? codexAccountPersistenceConflict(runtimeConfig, accountId, "create")
|
|
1262
|
+
: undefined;
|
|
1263
|
+
if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
|
|
1264
|
+
if (reauth) {
|
|
1265
|
+
if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
|
|
1266
|
+
if (!configuredPoolAccount(runtimeConfig, accountId)) {
|
|
1267
|
+
return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
pruneCodexLoginState();
|
|
1271
|
+
if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
|
|
1272
|
+
const busy = new CodexLoginStateBusyError();
|
|
1273
|
+
const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
|
|
1274
|
+
response.headers.set("Retry-After", "1");
|
|
1275
|
+
return response;
|
|
1276
|
+
}
|
|
1277
|
+
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1278
|
+
const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
|
|
1279
|
+
codexAuthLoginState.set(flowId, loginOwner);
|
|
1280
|
+
try {
|
|
1281
|
+
const { startLoginFlow, getLoginStatus } = await import("../oauth");
|
|
1282
|
+
const result = await startLoginFlow("chatgpt", { forceLogin: true });
|
|
1283
|
+
|
|
1284
|
+
// Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
|
|
1285
|
+
// The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
|
|
1286
|
+
if (result.url) {
|
|
1287
|
+
const { openUrl } = await import("../lib/open-url");
|
|
1288
|
+
openUrl(result.url);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
(async () => {
|
|
1292
|
+
try {
|
|
1293
|
+
let completed = false;
|
|
1294
|
+
for (let i = 0; i < 150; i++) {
|
|
1295
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1296
|
+
const st = getLoginStatus("chatgpt");
|
|
1297
|
+
if (st.done && st.loggedIn) {
|
|
1298
|
+
const { getCredential } = await import("../oauth/store");
|
|
1299
|
+
const cred = getCredential("chatgpt");
|
|
1300
|
+
if (cred) {
|
|
1301
|
+
const oauthAccountId = cred.accountId;
|
|
1302
|
+
if (!oauthAccountId) {
|
|
1303
|
+
setCodexLoginState(flowId, {
|
|
1304
|
+
status: "error",
|
|
1305
|
+
error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
|
|
1306
|
+
doneAt: Date.now(),
|
|
1307
|
+
});
|
|
1308
|
+
completed = true;
|
|
1309
|
+
break;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
let email = cred.email || accountId;
|
|
1313
|
+
let plan: string | undefined;
|
|
1314
|
+
let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
|
|
1315
|
+
try {
|
|
1316
|
+
const tokens = { access_token: cred.access, account_id: oauthAccountId };
|
|
1317
|
+
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
1318
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
|
|
1319
|
+
signal: AbortSignal.timeout(8000),
|
|
1320
|
+
});
|
|
1321
|
+
if (resp.ok) {
|
|
1322
|
+
const data = (await resp.json()) as WhamUsageResponse;
|
|
1323
|
+
email = data.email ?? email;
|
|
1324
|
+
plan = nonEmptyPlan(data.plan_type) ?? undefined;
|
|
1325
|
+
quota = parseUsageQuota(data);
|
|
1326
|
+
}
|
|
1327
|
+
} catch { /* wham fetch is non-blocking */ }
|
|
1328
|
+
// Reauth must refresh the same ChatGPT identity already bound to this pool slot.
|
|
1329
|
+
// Otherwise a different login would silently overwrite credentials under a trusted id.
|
|
1330
|
+
if (reauth) {
|
|
1331
|
+
const existingCred = getCodexAccountCredential(accountId);
|
|
1332
|
+
const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
|
|
1333
|
+
const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
|
|
1334
|
+
const expectedEmail = poolAccount?.email?.trim().toLowerCase();
|
|
1335
|
+
const gotEmail = email.trim().toLowerCase();
|
|
1336
|
+
if (expectedChatgptId) {
|
|
1337
|
+
if (expectedChatgptId !== oauthAccountId) {
|
|
1338
|
+
setCodexLoginState(flowId, {
|
|
1339
|
+
status: "error",
|
|
1340
|
+
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
1341
|
+
doneAt: Date.now(),
|
|
1342
|
+
});
|
|
1343
|
+
completed = true;
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
1346
|
+
} else if (expectedEmail) {
|
|
1347
|
+
if (!gotEmail || gotEmail !== expectedEmail) {
|
|
1348
|
+
setCodexLoginState(flowId, {
|
|
1349
|
+
status: "error",
|
|
1350
|
+
error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
|
|
1351
|
+
doneAt: Date.now(),
|
|
1352
|
+
});
|
|
1353
|
+
completed = true;
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
} else {
|
|
1357
|
+
// No chatgptAccountId and no pool email — refuse silent identity replacement
|
|
1358
|
+
// (including empty credential slots that still have a pool row).
|
|
1359
|
+
setCodexLoginState(flowId, {
|
|
1360
|
+
status: "error",
|
|
1361
|
+
error: "Cannot verify account identity for reauth. Remove this account and add it again.",
|
|
1362
|
+
doneAt: Date.now(),
|
|
1363
|
+
});
|
|
1364
|
+
completed = true;
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
|
|
1370
|
+
const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
|
|
1371
|
+
if (collision.collision) {
|
|
1372
|
+
setCodexLoginState(flowId, {
|
|
1373
|
+
status: "error", error: collision.reason, doneAt: Date.now(),
|
|
1374
|
+
});
|
|
1375
|
+
completed = true;
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
|
|
1380
|
+
if (!warmup.ok) {
|
|
1381
|
+
const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
|
|
1382
|
+
setCodexLoginState(flowId, {
|
|
1383
|
+
status: "error",
|
|
1384
|
+
error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
|
|
1385
|
+
doneAt: Date.now(),
|
|
1386
|
+
});
|
|
1387
|
+
completed = true;
|
|
1388
|
+
break;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const latestConfig = getRuntimeConfig(config);
|
|
1392
|
+
const accounts = latestConfig.codexAccounts ?? [];
|
|
1393
|
+
const existingIdx = accounts.findIndex(account => account.id === accountId);
|
|
1394
|
+
const commitConflict = codexAccountPersistenceConflict(
|
|
1395
|
+
latestConfig,
|
|
1396
|
+
accountId,
|
|
1397
|
+
reauth ? "reauth" : "create",
|
|
1398
|
+
);
|
|
1399
|
+
if (commitConflict) {
|
|
1400
|
+
setCodexLoginState(flowId, {
|
|
1401
|
+
status: "error",
|
|
1402
|
+
error: commitConflict,
|
|
1403
|
+
doneAt: Date.now(),
|
|
1404
|
+
});
|
|
1405
|
+
completed = true;
|
|
1406
|
+
break;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
saveCodexAccountCredential(accountId, {
|
|
1410
|
+
accessToken: cred.access,
|
|
1411
|
+
refreshToken: cred.refresh,
|
|
1412
|
+
expiresAt: cred.expires,
|
|
1413
|
+
chatgptAccountId: oauthAccountId,
|
|
1414
|
+
});
|
|
1415
|
+
// A successful reauthentication replaces the credential generation. Do not let a
|
|
1416
|
+
// failed optional WHAM probe make the replacement inherit quota from the old record.
|
|
1417
|
+
if (reauth) clearAccountQuota(accountId);
|
|
1418
|
+
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
1419
|
+
clearAccountNeedsReauth(accountId);
|
|
1420
|
+
if (quota) {
|
|
1421
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (existingIdx >= 0) {
|
|
1425
|
+
// Keep the pool id stable; refresh display metadata after a successful login/reauth.
|
|
1426
|
+
accounts[existingIdx] = withCodexAccountLogLabel({
|
|
1427
|
+
...accounts[existingIdx],
|
|
1428
|
+
email,
|
|
1429
|
+
plan: plan ?? accounts[existingIdx].plan,
|
|
1430
|
+
isMain: false,
|
|
1431
|
+
}, accounts);
|
|
1432
|
+
latestConfig.codexAccounts = accounts;
|
|
1433
|
+
saveRuntimeConfig(config, latestConfig);
|
|
1434
|
+
} else {
|
|
1435
|
+
accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
|
|
1436
|
+
latestConfig.codexAccounts = accounts;
|
|
1437
|
+
saveRuntimeConfig(config, latestConfig);
|
|
1438
|
+
}
|
|
1439
|
+
reconcileLiveStateStores();
|
|
1440
|
+
setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
|
|
1441
|
+
completed = true;
|
|
1442
|
+
}
|
|
1443
|
+
break;
|
|
1444
|
+
}
|
|
1445
|
+
if (st.done && st.error) {
|
|
1446
|
+
setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() });
|
|
1447
|
+
completed = true;
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
if (!completed) {
|
|
1452
|
+
setCodexLoginState(flowId, {
|
|
1453
|
+
status: "error",
|
|
1454
|
+
error: "Login timed out before OAuth completed.",
|
|
1455
|
+
doneAt: Date.now(),
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
const message = error instanceof ConfigMutationLockError
|
|
1460
|
+
|| error instanceof CodexCredentialRefreshLockTimeoutError
|
|
1461
|
+
? "Configuration is busy; retry login shortly."
|
|
1462
|
+
: error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
|
|
1463
|
+
? "Credential refresh is busy; retry login shortly."
|
|
1464
|
+
: error instanceof Error ? error.message : String(error);
|
|
1465
|
+
setCodexLoginState(flowId, {
|
|
1466
|
+
status: "error",
|
|
1467
|
+
error: message,
|
|
1468
|
+
doneAt: Date.now(),
|
|
1469
|
+
});
|
|
1470
|
+
} finally {
|
|
1471
|
+
// TTL: keep completed flow state available for clients that miss a short polling window.
|
|
1472
|
+
setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
|
|
1473
|
+
}
|
|
1474
|
+
})();
|
|
1475
|
+
|
|
1476
|
+
setCodexLoginState(flowId, { status: "pending" });
|
|
1477
|
+
return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
|
|
1478
|
+
} catch (e) {
|
|
1479
|
+
if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
|
|
1480
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1481
|
+
if (msg.includes("already in progress")) {
|
|
1482
|
+
return jsonResponse({ error: msg, status: "pending" }, 409);
|
|
1483
|
+
}
|
|
1484
|
+
if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
|
|
1485
|
+
const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
|
|
1486
|
+
response.headers.set("Retry-After", "1");
|
|
1487
|
+
return response;
|
|
1488
|
+
}
|
|
1489
|
+
return jsonResponse({ error: msg }, 500);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
|
|
1494
|
+
const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
|
|
1495
|
+
const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
|
|
1496
|
+
const input = typeof body.input === "string" ? body.input : "";
|
|
1497
|
+
if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
|
|
1498
|
+
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
|
|
1499
|
+
|
|
1500
|
+
// Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
|
|
1501
|
+
const { submitManualLoginCode } = await import("../oauth");
|
|
1502
|
+
const flow = codexAuthLoginState.get(flowId);
|
|
1503
|
+
if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
|
|
1504
|
+
if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
|
|
1505
|
+
|
|
1506
|
+
const result = submitManualLoginCode("chatgpt", input);
|
|
1507
|
+
if (!result.ok) return jsonResponse({ error: result.error }, 400);
|
|
1508
|
+
return jsonResponse({ ok: true }, 202);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
|
|
1512
|
+
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
|
|
1513
|
+
const { cancelLoginFlow } = await import("../oauth");
|
|
1514
|
+
const cancelled = cancelLoginFlow("chatgpt");
|
|
1515
|
+
expireCodexAuthFlow(body.flowId ?? null);
|
|
1516
|
+
return jsonResponse({ ok: true, cancelled });
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
|
|
1520
|
+
const flowId = url.searchParams.get("flowId");
|
|
1521
|
+
const accountId = url.searchParams.get("accountId")?.trim();
|
|
1522
|
+
// Reauth always has a pre-existing credential; never treat "credential exists" as success
|
|
1523
|
+
// when the flow map entry is gone (would false-complete on lost/expired flow state).
|
|
1524
|
+
const reauthStatus = url.searchParams.get("reauth") === "1";
|
|
1525
|
+
if (flowId) {
|
|
1526
|
+
const st = codexAuthLoginState.get(flowId);
|
|
1527
|
+
if (!st && accountId && !reauthStatus && getCodexAccountCredential(accountId)) {
|
|
1528
|
+
return jsonResponse({ status: "done", accountId });
|
|
1529
|
+
}
|
|
1530
|
+
return jsonResponse(st ? { ...st, email: st.email ?? undefined } : { status: "expired" });
|
|
1531
|
+
}
|
|
1532
|
+
// Legacy fallback: return latest pending flow
|
|
1533
|
+
for (const [, st] of codexAuthLoginState) {
|
|
1534
|
+
if (st.status === "pending") return jsonResponse({ ...st, email: st.email ?? undefined });
|
|
1535
|
+
}
|
|
1536
|
+
return jsonResponse({ status: "idle" });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|