@remodex/rmx 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +91 -0
- package/LICENSE +21 -0
- package/README.md +242 -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 +584 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-CZqebSPQ.css +1 -0
- package/gui/dist/assets/index-CkETtt7P.js +71 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/fonts/google-sans-cyrillic.woff2 +0 -0
- package/gui/dist/fonts/google-sans-latin.woff2 +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/claude-color.svg +1 -0
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.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/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-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/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 +2 -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 +118 -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 +1205 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +83 -0
- package/src/adapters/client-fingerprint.ts +59 -0
- package/src/adapters/command-code.ts +453 -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 +139 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +250 -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 +1235 -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 +59 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +332 -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 +604 -0
- package/src/adapters/cursor/protobuf-events.ts +735 -0
- package/src/adapters/cursor/protobuf-request.ts +719 -0
- package/src/adapters/cursor/request-builder.ts +280 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +621 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +59 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +520 -0
- package/src/adapters/google-antigravity-wire.ts +140 -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 +24 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +859 -0
- package/src/adapters/identity.ts +77 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +208 -0
- package/src/adapters/kiro-events.ts +197 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +104 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +224 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1924 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1265 -0
- package/src/adapters/openai-responses.ts +1309 -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/android-remote/assets.ts +218 -0
- package/src/android-remote/attachments.ts +168 -0
- package/src/android-remote/auth.ts +237 -0
- package/src/android-remote/cloudflare-provisioning.ts +409 -0
- package/src/android-remote/cloudflare-secret.ts +106 -0
- package/src/android-remote/cloudflare-tunnel.ts +488 -0
- package/src/android-remote/cloudflared.ts +286 -0
- package/src/android-remote/codex-app-server.ts +565 -0
- package/src/android-remote/desktop-history-page.ts +936 -0
- package/src/android-remote/desktop-ipc.ts +3643 -0
- package/src/android-remote/desktop-ownership-store.ts +98 -0
- package/src/android-remote/desktop-project-registration.ts +129 -0
- package/src/android-remote/desktop-session-stream.ts +1110 -0
- package/src/android-remote/desktop-workspace-state.ts +443 -0
- package/src/android-remote/file-change-parser.ts +112 -0
- package/src/android-remote/gateway.ts +9108 -0
- package/src/android-remote/mutation-store.ts +299 -0
- package/src/android-remote/projection.ts +1780 -0
- package/src/android-remote/queued-turn-store.ts +248 -0
- package/src/android-remote/session-command-recovery.ts +1384 -0
- package/src/android-remote/store.ts +466 -0
- package/src/android-remote/thread-reconciliation.ts +133 -0
- package/src/android-remote/thread-source-paths.ts +307 -0
- package/src/android-remote/thread-stream.ts +546 -0
- package/src/android-remote/turn-activity.ts +235 -0
- package/src/android-remote/user-message-identity.ts +94 -0
- package/src/bridge.ts +1793 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +821 -0
- package/src/claude/agents-inject.ts +267 -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 +601 -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 +519 -0
- package/src/claude/model-info.ts +154 -0
- package/src/claude/outbound.ts +898 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +296 -0
- package/src/cli/account-auth.ts +250 -0
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +476 -0
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +297 -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 +211 -0
- package/src/cli/claude.ts +302 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/codex-shim-readiness.ts +69 -0
- package/src/cli/combo.ts +124 -0
- package/src/cli/config-command.ts +183 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/desktop-first-run.ts +25 -0
- package/src/cli/doctor.ts +1022 -0
- package/src/cli/export-command.ts +201 -0
- package/src/cli/help.ts +370 -0
- package/src/cli/index.ts +1565 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +225 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +35 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/models-runtime.ts +224 -0
- package/src/cli/models.ts +340 -0
- package/src/cli/observe.ts +170 -0
- package/src/cli/opencode.ts +587 -0
- package/src/cli/provider-runtime.ts +179 -0
- package/src/cli/provider.ts +476 -0
- package/src/cli/ready.ts +301 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +328 -0
- package/src/cli/star-prompt.ts +211 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/system-command.ts +196 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +205 -0
- package/src/cli/v2.ts +200 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +1109 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +172 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +195 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +40 -0
- package/src/codex/admission.ts +263 -0
- package/src/codex/app-server-processes.ts +799 -0
- package/src/codex/auth-api.ts +2098 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +480 -0
- package/src/codex/autostart-health.ts +156 -0
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +471 -0
- package/src/codex/catalog/bundled.ts +533 -0
- package/src/codex/catalog/effort.ts +432 -0
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +287 -0
- package/src/codex/catalog/native-models.ts +7 -0
- package/src/codex/catalog/parsing.ts +503 -0
- package/src/codex/catalog/provider-fetch.ts +2267 -0
- package/src/codex/catalog/sync.ts +1606 -0
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-refresh-status.ts +87 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +15 -0
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +580 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/desktop-client-processes.ts +521 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +1091 -0
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +347 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +115 -0
- package/src/codex/history-provider.ts +1075 -0
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +204 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject-coordination.ts +257 -0
- package/src/codex/inject.ts +1857 -0
- package/src/codex/injected-marker.ts +79 -0
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +105 -0
- package/src/codex/journal.ts +172 -0
- package/src/codex/main-account-cache.ts +56 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/model-cache.ts +267 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +162 -0
- package/src/codex/native-main-owner.ts +329 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1531 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +348 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +691 -0
- package/src/codex/paths.ts +78 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +295 -0
- package/src/codex/project-config-warnings.ts +426 -0
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/provider-adoption.ts +242 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +494 -0
- package/src/codex/refresh.ts +60 -0
- package/src/codex/routing.ts +1855 -0
- package/src/codex/runtime.ts +659 -0
- package/src/codex/shim.ts +1215 -0
- package/src/codex/subagent-defaults.ts +557 -0
- package/src/codex/subagent-model-fallback.ts +560 -0
- package/src/codex/sync.ts +238 -0
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +374 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +44 -0
- package/src/combos/request.ts +64 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +392 -0
- package/src/config.ts +3270 -0
- package/src/generated/model-metadata.ts +144 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +540 -0
- package/src/grok/inspect.ts +45 -0
- package/src/grok/status.ts +127 -0
- package/src/grok/sync.ts +66 -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 +922 -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/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +108 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/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 +242 -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 +184 -0
- package/src/lib/bun-stream-caps.ts +127 -0
- package/src/lib/config-ownership.ts +438 -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/local-management-attestation.ts +51 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +182 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +168 -0
- package/src/lib/provider-environment.ts +470 -0
- package/src/lib/provider-outbound.ts +203 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +510 -0
- package/src/lib/remodex-home.ts +616 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +178 -0
- package/src/lib/shadow-call.ts +54 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +364 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/test-home-guard.ts +98 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +366 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +508 -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 +817 -0
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +402 -0
- package/src/model-sources.ts +73 -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/command-code.ts +239 -0
- package/src/oauth/cursor.ts +231 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +443 -0
- package/src/oauth/index.ts +1280 -0
- package/src/oauth/key-providers.ts +128 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +621 -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 +655 -0
- package/src/oauth/token-guardian.ts +309 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -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 +317 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/command-code-efforts.ts +85 -0
- package/src/providers/context-cap.ts +73 -0
- package/src/providers/derive.ts +451 -0
- package/src/providers/free-directory.ts +187 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +271 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +361 -0
- package/src/providers/openai-sidecar.ts +235 -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 +83 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +179 -0
- package/src/providers/quota.ts +1942 -0
- package/src/providers/reasoning-capabilities.ts +336 -0
- package/src/providers/registry.ts +2375 -0
- package/src/providers/slug-codec.ts +74 -0
- package/src/providers/xai-transport.ts +149 -0
- package/src/reasoning-effort.ts +243 -0
- package/src/responses/compaction.ts +124 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +714 -0
- package/src/responses/reasoning-envelope.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +106 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +431 -0
- package/src/responses/state.ts +1039 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +802 -0
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +205 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +605 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +424 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/adapter-resolve.ts +83 -0
- package/src/server/auth-cors.ts +606 -0
- package/src/server/chat-completions.ts +379 -0
- package/src/server/claude-messages.ts +980 -0
- package/src/server/effort-policy.ts +251 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +152 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +485 -0
- package/src/server/index.ts +1633 -0
- package/src/server/lifecycle.ts +482 -0
- package/src/server/live.ts +609 -0
- package/src/server/management/agent-settings-routes.ts +1180 -0
- package/src/server/management/android-remote-routes.ts +390 -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/combo-routes.ts +244 -0
- package/src/server/management/config-routes.ts +602 -0
- package/src/server/management/context.ts +88 -0
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +516 -0
- package/src/server/management/model-routes.ts +519 -0
- package/src/server/management/model-rows.ts +143 -0
- package/src/server/management/native-integration-routes.ts +781 -0
- package/src/server/management/oauth-account-routes.ts +573 -0
- package/src/server/management/provider-routes.ts +781 -0
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +384 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +106 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +433 -0
- package/src/server/management/system-routes.ts +141 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +269 -0
- package/src/server/management-auth.ts +353 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +156 -0
- package/src/server/proxy-liveness.ts +326 -0
- package/src/server/proxy-stop.ts +92 -0
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +353 -0
- package/src/server/relay.ts +1179 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1072 -0
- package/src/server/responses/collaboration.ts +409 -0
- package/src/server/responses/compact.ts +710 -0
- package/src/server/responses/core.ts +3561 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +171 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/policy-fallback.ts +152 -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 +272 -0
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses.ts +10 -0
- package/src/server/search.ts +181 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +263 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +119 -0
- package/src/server/system-env.ts +418 -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 +470 -0
- package/src/service-manager-probe.ts +824 -0
- package/src/service.ts +3011 -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 +364 -0
- package/src/tray/windows.ts +738 -0
- package/src/types.ts +1531 -0
- package/src/update/badge.ts +72 -0
- package/src/update/desktop-release.ts +1620 -0
- package/src/update/index.ts +402 -0
- package/src/update/job.ts +1906 -0
- package/src/update/notify.ts +261 -0
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -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 +283 -0
- package/src/usage/log.ts +695 -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 +127 -0
- package/src/vision/index.ts +558 -0
- package/src/vision/reasoning.ts +55 -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 +791 -0
- package/src/web-search/parse.ts +235 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
|
@@ -0,0 +1,3561 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
|
+
import { formatPassthroughUpstreamError } from "./passthrough-error";
|
|
4
|
+
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
5
|
+
import {
|
|
6
|
+
getConfigPath,
|
|
7
|
+
multiAgentGuidanceEnabled,
|
|
8
|
+
resolveEnvValue,
|
|
9
|
+
} from "../../config";
|
|
10
|
+
import { parseRequest } from "../../responses/parser";
|
|
11
|
+
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
|
|
12
|
+
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
|
|
13
|
+
import {
|
|
14
|
+
expandPreviousResponseInput,
|
|
15
|
+
previousResponseProviderState,
|
|
16
|
+
previousResponseReplayFailure,
|
|
17
|
+
rememberResponseState,
|
|
18
|
+
} from "../../responses/state";
|
|
19
|
+
import { comboRouteDecisionTrace, NoEligiblePolicyCandidateError, routeModel, type RouteResult } from "../../router";
|
|
20
|
+
import { evidenceFromBody } from "../../routing/request-evidence";
|
|
21
|
+
import {
|
|
22
|
+
advanceComboAfterFailure,
|
|
23
|
+
comboDefaultEffort,
|
|
24
|
+
comboFailureDecision,
|
|
25
|
+
comboIdFromRawBody,
|
|
26
|
+
concreteComboRequestBody,
|
|
27
|
+
getCombo,
|
|
28
|
+
isComboTargetInCooldown,
|
|
29
|
+
NoAvailableComboTargetsError,
|
|
30
|
+
noteComboSuccess,
|
|
31
|
+
parseRetryAfterMs,
|
|
32
|
+
pickComboTarget,
|
|
33
|
+
targetKey,
|
|
34
|
+
} from "../../combos";
|
|
35
|
+
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
36
|
+
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
37
|
+
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
38
|
+
import { modelInList, namespacedToolName } from "../../types";
|
|
39
|
+
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
40
|
+
import {
|
|
41
|
+
forceRefreshOAuthAccessSnapshot,
|
|
42
|
+
getOAuthCredentialApiBaseUrl,
|
|
43
|
+
getValidAccessTokenForAccount,
|
|
44
|
+
getValidAccessTokenSnapshot,
|
|
45
|
+
type OAuthAccessSnapshot,
|
|
46
|
+
UnsupportedOAuthProviderError,
|
|
47
|
+
} from "../../oauth";
|
|
48
|
+
import {
|
|
49
|
+
ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
|
|
50
|
+
anthropicSessionKeyFromParts,
|
|
51
|
+
bindAnthropicSessionAffinity,
|
|
52
|
+
formatAnthropicProviderForLog,
|
|
53
|
+
getAnthropicPoolAccessToken,
|
|
54
|
+
getAnthropicPoolRetryAfterSeconds,
|
|
55
|
+
isAnthropicAccountPoolEnabled,
|
|
56
|
+
promoteAnthropicActiveAccount,
|
|
57
|
+
resolveAnthropicAccountForSession,
|
|
58
|
+
rotateAnthropicAccountOn429,
|
|
59
|
+
} from "../../oauth/anthropic-routing";
|
|
60
|
+
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
61
|
+
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
62
|
+
import { describeImagesInPlace, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
63
|
+
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
64
|
+
import {
|
|
65
|
+
applyCodexAuthContextToProvider,
|
|
66
|
+
CodexAccountCooldownError,
|
|
67
|
+
codexMainProfileDrainingResponse,
|
|
68
|
+
cooldownErrorResponse,
|
|
69
|
+
CodexAuthContextError,
|
|
70
|
+
CodexDirectAuthenticationError,
|
|
71
|
+
CodexMainProfileDrainingError,
|
|
72
|
+
CodexPoolAuthenticationError,
|
|
73
|
+
CodexThreadAffinityExpiredError,
|
|
74
|
+
headersForCodexAuthContext,
|
|
75
|
+
isCodexAuthContextUsable,
|
|
76
|
+
resolveCodexAuthContext,
|
|
77
|
+
codexProbeLeaseId,
|
|
78
|
+
codexProbeQuotaScope,
|
|
79
|
+
releaseCodexAuthContextProbeLease,
|
|
80
|
+
stripCodexRuntimeProviderFields,
|
|
81
|
+
type CodexAuthContext,
|
|
82
|
+
} from "../../codex/auth-context";
|
|
83
|
+
import {
|
|
84
|
+
computeQuotaCooldown,
|
|
85
|
+
formatCodexProviderForLog,
|
|
86
|
+
previewCodexAccountForRequest,
|
|
87
|
+
recordCodexUpstreamOutcome,
|
|
88
|
+
type CodexUpstreamOutcome,
|
|
89
|
+
} from "../../codex/routing";
|
|
90
|
+
import {
|
|
91
|
+
applyUpstreamRecoveryInit,
|
|
92
|
+
fetchWithResetRetry,
|
|
93
|
+
fetchWithTransientNonJsonRetry,
|
|
94
|
+
fetchWithTransientRetry,
|
|
95
|
+
prepareSameTarget429Wait,
|
|
96
|
+
} from "../../lib/upstream-retry";
|
|
97
|
+
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
98
|
+
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
|
|
99
|
+
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
100
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
101
|
+
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
102
|
+
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
103
|
+
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
104
|
+
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
|
|
105
|
+
import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
|
|
106
|
+
import {
|
|
107
|
+
providerModelResponsesUpstreamStreaming,
|
|
108
|
+
providerPreStreamNonJsonRetryStatuses,
|
|
109
|
+
type InboundWire,
|
|
110
|
+
} from "../../providers/registry";
|
|
111
|
+
import type { AdapterRequest } from "../../adapters/base";
|
|
112
|
+
import {
|
|
113
|
+
hasKeyPoolFailover,
|
|
114
|
+
rateLimitRetryDelayMs,
|
|
115
|
+
rateLimitRetryPolicyFor,
|
|
116
|
+
rotateProviderTransportOn429,
|
|
117
|
+
} from "../../providers/key-failover";
|
|
118
|
+
import { shouldAttemptImageTierRetry } from "../image-retry";
|
|
119
|
+
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
120
|
+
import type { WsData } from "../ws-bridge";
|
|
121
|
+
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
122
|
+
import { redactSecretString } from "../../lib/redact";
|
|
123
|
+
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
124
|
+
import type { AdmissionLease } from "../../lib/admission";
|
|
125
|
+
import { applyReasoningCapability, isThreadSpawnRequest, supportedLadderFor } from "../effort-policy";
|
|
126
|
+
import {
|
|
127
|
+
applySubagentModelFallback,
|
|
128
|
+
maybePrimeSubagentQuota,
|
|
129
|
+
recordSubagentQuotaFailureForThreadSpawn,
|
|
130
|
+
} from "../../codex/subagent-model-fallback";
|
|
131
|
+
import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
|
|
132
|
+
import {
|
|
133
|
+
beginRequestAttempt,
|
|
134
|
+
catalogModelSupportsServiceTier,
|
|
135
|
+
finishRequestAttempt,
|
|
136
|
+
inspectResponseLogJson,
|
|
137
|
+
noteAttemptSend,
|
|
138
|
+
readConfiguredCodexServiceTier,
|
|
139
|
+
recordAdapterReasoning,
|
|
140
|
+
recordAttemptRequestedEffort,
|
|
141
|
+
requestLogSpeedLabel,
|
|
142
|
+
sealRequestAttemptIdentity,
|
|
143
|
+
usageFromResponsesPayload,
|
|
144
|
+
type RequestLogContext,
|
|
145
|
+
} from "../request-log";
|
|
146
|
+
import {
|
|
147
|
+
conversationIdFromResponsesRequest,
|
|
148
|
+
normalizeLogConversationId,
|
|
149
|
+
sessionIdHeaderFromRequest,
|
|
150
|
+
} from "../request-log-conversation";
|
|
151
|
+
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
152
|
+
import {
|
|
153
|
+
consumeForInspection,
|
|
154
|
+
consumeForResponseLogMetadata,
|
|
155
|
+
createSseInspector,
|
|
156
|
+
isEagerRelaySseResponse,
|
|
157
|
+
isNativePassthroughSseResponse,
|
|
158
|
+
markEagerRelaySseResponse,
|
|
159
|
+
markNativePassthroughSseResponse,
|
|
160
|
+
relaySseWithFailedTail,
|
|
161
|
+
relayWithAbort,
|
|
162
|
+
sanitizePassthroughHeaders,
|
|
163
|
+
} from "../relay";
|
|
164
|
+
import { relaySseEagerBounded } from "../relay-eager";
|
|
165
|
+
import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps";
|
|
166
|
+
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
167
|
+
import {
|
|
168
|
+
createResponsesItemIdPayloadRewrite,
|
|
169
|
+
hasResponsesItemIdRepair,
|
|
170
|
+
repairResponsesJsonItemIds,
|
|
171
|
+
} from "../responses-item-id-repair";
|
|
172
|
+
import {
|
|
173
|
+
createImageGenCallRestoreRewrite,
|
|
174
|
+
imageGenToolCallAliases,
|
|
175
|
+
restoreImageGenCallsInJson,
|
|
176
|
+
} from "../responses-image-gen-repair";
|
|
177
|
+
import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite";
|
|
178
|
+
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
179
|
+
|
|
180
|
+
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
181
|
+
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
|
|
182
|
+
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
|
|
183
|
+
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
|
|
184
|
+
import {
|
|
185
|
+
acquireUpstreamHostAdmission,
|
|
186
|
+
disableUpstreamHostCircuitForKey,
|
|
187
|
+
normalizeUpstreamHostCircuitThreshold,
|
|
188
|
+
recordUpstreamHostFailure,
|
|
189
|
+
releaseUpstreamHostAdmission,
|
|
190
|
+
resetUpstreamHostHealth,
|
|
191
|
+
upstreamHostHealthKey,
|
|
192
|
+
type UpstreamHostAdmissionLease,
|
|
193
|
+
} from "../../codex/upstream-host-health";
|
|
194
|
+
import {
|
|
195
|
+
createResponsesSnapshotBlockRewrite,
|
|
196
|
+
hasResponsesSnapshotRepair,
|
|
197
|
+
repairResponsesSnapshotJson,
|
|
198
|
+
} from "../responses-snapshot-repair";
|
|
199
|
+
import {
|
|
200
|
+
composeSseBlockRewrites,
|
|
201
|
+
composeSsePayloadRewrites,
|
|
202
|
+
payloadRewriteAsBlockRewrite,
|
|
203
|
+
relaySseWithBlockRewrite,
|
|
204
|
+
} from "../sse-payload-rewrite";
|
|
205
|
+
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
|
|
206
|
+
import { responsesJsonToSseBody } from "../responses-json-events";
|
|
207
|
+
import { guardTerminalEventStream } from "./terminal-guard";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Adapters whose continuation state must survive Codex's store:false requests.
|
|
211
|
+
*/
|
|
212
|
+
export function adapterNeedsForcedContinuation(name: string): boolean {
|
|
213
|
+
return name === "kiro" || name === "cursor";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function sidecarOutcomeRecorder(
|
|
217
|
+
config: OcxConfig,
|
|
218
|
+
authCtx: CodexAuthContext,
|
|
219
|
+
threadId?: string | null,
|
|
220
|
+
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
221
|
+
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
222
|
+
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
223
|
+
threadId,
|
|
224
|
+
fixedAccount: authCtx.fixedAccount,
|
|
225
|
+
probeLeaseId: authCtx.probeLeaseId,
|
|
226
|
+
probeQuotaScope: authCtx.probeQuotaScope,
|
|
227
|
+
writerGeneration: authCtx.writerGeneration,
|
|
228
|
+
})
|
|
229
|
+
: undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
import { isShadowSourceModel, shouldInterceptShadowCall } from "../../lib/shadow-call";
|
|
235
|
+
|
|
236
|
+
export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
|
|
241
|
+
return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isFixedCodexAccount(authCtx: CodexAuthContext): boolean {
|
|
245
|
+
return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
246
|
+
&& authCtx.fixedAccount === true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
export function usesCodexForwardPoolAuth(
|
|
252
|
+
authCtx: CodexAuthContext,
|
|
253
|
+
provider: OcxProviderConfig,
|
|
254
|
+
): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
|
|
255
|
+
return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
256
|
+
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function preAuthUpstreamHostCircuitKey(
|
|
260
|
+
route: Pick<RouteResult, "provider" | "providerName" | "codexAccountMode" | "codexAccountId">,
|
|
261
|
+
config: OcxConfig,
|
|
262
|
+
options: { requireResponsesAdapter?: boolean } = {},
|
|
263
|
+
): string | null {
|
|
264
|
+
if (
|
|
265
|
+
normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) === 0
|
|
266
|
+
|| route.codexAccountMode !== "pool"
|
|
267
|
+
|| route.codexAccountId !== undefined
|
|
268
|
+
|| route.provider.authMode !== "forward"
|
|
269
|
+
|| (options.requireResponsesAdapter !== false && route.provider.adapter !== "openai-responses")
|
|
270
|
+
) return null;
|
|
271
|
+
return upstreamHostHealthKey(route.providerName, safeOriginLabel(route.provider.baseUrl ?? ""));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function upstreamHostCircuitOpenResponse(retryAfterSeconds: number): Response {
|
|
275
|
+
return formatErrorResponse(
|
|
276
|
+
503,
|
|
277
|
+
"upstream_host_circuit_open",
|
|
278
|
+
"Provider host is temporarily unavailable",
|
|
279
|
+
{ retryAfter: String(retryAfterSeconds) },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeCodexUnsupportedModelDetail(value: string): string {
|
|
284
|
+
return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isAllowListedCodexAccountModel400(
|
|
288
|
+
status: number,
|
|
289
|
+
bodyText: string,
|
|
290
|
+
modelId: string,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (status !== 400) return false;
|
|
293
|
+
try {
|
|
294
|
+
const payload = JSON.parse(bodyText) as unknown;
|
|
295
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
296
|
+
const detail = (payload as { detail?: unknown }).detail;
|
|
297
|
+
if (typeof detail !== "string") return false;
|
|
298
|
+
const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`;
|
|
299
|
+
return normalizeCodexUnsupportedModelDetail(detail)
|
|
300
|
+
=== normalizeCodexUnsupportedModelDetail(expected);
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function shouldRetryCodexPoolAccountModel400(
|
|
307
|
+
response: Response,
|
|
308
|
+
modelId: string,
|
|
309
|
+
signal?: AbortSignal,
|
|
310
|
+
): Promise<boolean> {
|
|
311
|
+
if (response.status !== 400) return false;
|
|
312
|
+
try {
|
|
313
|
+
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
314
|
+
return body.displaySafe
|
|
315
|
+
&& !body.truncated
|
|
316
|
+
&& isAllowListedCodexAccountModel400(response.status, body.text, modelId);
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
|
|
323
|
+
export function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
|
|
324
|
+
return response.status === 402 || response.status === 429;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
interface CodexPoolAccountRetryArgs {
|
|
328
|
+
req: Request;
|
|
329
|
+
config: OcxConfig;
|
|
330
|
+
route: { providerName: string; modelId: string; provider: OcxProviderConfig };
|
|
331
|
+
parsed: OcxParsedRequest;
|
|
332
|
+
logCtx: RequestLogContext;
|
|
333
|
+
options: {
|
|
334
|
+
abortSignal?: AbortSignal;
|
|
335
|
+
onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
|
|
336
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
337
|
+
// Narrowed subset of HandleResponsesOptions: the retry rebuilds the adapter, so it
|
|
338
|
+
// needs the inbound scope or the retry could land on a different wire than the
|
|
339
|
+
// first attempt.
|
|
340
|
+
inboundWire?: InboundWire;
|
|
341
|
+
translatorBudget: TranslatorBudget;
|
|
342
|
+
turnAdmissionLease?: AdmissionLease;
|
|
343
|
+
};
|
|
344
|
+
firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
345
|
+
firstResponse: Response;
|
|
346
|
+
outcomeStatus: number;
|
|
347
|
+
upstream: AbortController;
|
|
348
|
+
connectMs: number;
|
|
349
|
+
passthroughEstimate?: number;
|
|
350
|
+
stream: boolean;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type CodexPoolAccountRetryResult =
|
|
354
|
+
| {
|
|
355
|
+
kind: "retried";
|
|
356
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
357
|
+
request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
|
|
358
|
+
upstreamResponse: Response;
|
|
359
|
+
selectedForwardHeaders: Headers;
|
|
360
|
+
}
|
|
361
|
+
| { kind: "no-alternate" }
|
|
362
|
+
| {
|
|
363
|
+
kind: "transport";
|
|
364
|
+
error: unknown;
|
|
365
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
function codexQuotaOutcomeMeta(response: Response): {
|
|
369
|
+
retryAfter: string | null;
|
|
370
|
+
resetAt: string[];
|
|
371
|
+
} {
|
|
372
|
+
return {
|
|
373
|
+
retryAfter: response.headers.get("retry-after"),
|
|
374
|
+
resetAt: [
|
|
375
|
+
response.headers.get("x-codex-primary-reset-at"),
|
|
376
|
+
response.headers.get("x-codex-secondary-reset-at"),
|
|
377
|
+
response.headers.get("x-codex-tertiary-reset-at"),
|
|
378
|
+
].filter((value): value is string => !!value),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* A reset timestamp describes a quota window, not an explicit instruction to
|
|
384
|
+
* stop using the whole account. A combo may therefore try a later model in the
|
|
385
|
+
* same request, while Retry-After and headerless quota failures remain blocking.
|
|
386
|
+
*/
|
|
387
|
+
function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
|
|
388
|
+
return enabled === true
|
|
389
|
+
&& (response.status === 429 || response.status === 402)
|
|
390
|
+
&& computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* One bounded alternate-account retry for Codex pool auth. Used for allow-listed
|
|
395
|
+
* model-400 and for pre-stream 429/402 quota failures (#584).
|
|
396
|
+
*/
|
|
397
|
+
async function retryCodexPoolOnAlternateAccount(
|
|
398
|
+
args: CodexPoolAccountRetryArgs,
|
|
399
|
+
): Promise<CodexPoolAccountRetryResult> {
|
|
400
|
+
const {
|
|
401
|
+
req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
|
|
402
|
+
outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
|
|
403
|
+
} = args;
|
|
404
|
+
// Defense in depth: exact account selectors must never reach alternate-account resolution,
|
|
405
|
+
// even if a future caller forgets to guard this helper.
|
|
406
|
+
if (firstAuthCtx.fixedAccount) return { kind: "no-alternate" };
|
|
407
|
+
const inboundWire = options.inboundWire ?? "responses";
|
|
408
|
+
let retryAuthCtx: CodexAuthContext | undefined;
|
|
409
|
+
try {
|
|
410
|
+
retryAuthCtx = await resolveCodexAuthContext(
|
|
411
|
+
req.headers,
|
|
412
|
+
config,
|
|
413
|
+
"pool",
|
|
414
|
+
{
|
|
415
|
+
excludeAccountId: firstAuthCtx.accountId,
|
|
416
|
+
modelId: route.modelId,
|
|
417
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (
|
|
422
|
+
!(error instanceof CodexPoolAuthenticationError)
|
|
423
|
+
&& !(error instanceof CodexAuthContextError)
|
|
424
|
+
&& !(error instanceof CodexAccountCooldownError)
|
|
425
|
+
&& !(error instanceof CodexMainProfileDrainingError)
|
|
426
|
+
) throw error;
|
|
427
|
+
}
|
|
428
|
+
if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
|
|
429
|
+
return { kind: "no-alternate" };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
|
|
433
|
+
if (outcomeStatus === 429 || outcomeStatus === 402) {
|
|
434
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
435
|
+
applyAccountQuotaFromUpstreamHeaders(
|
|
436
|
+
firstAuthCtx.accountId,
|
|
437
|
+
firstResponse.headers,
|
|
438
|
+
firstAuthCtx.writerGeneration,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
|
|
442
|
+
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
443
|
+
...quotaMeta,
|
|
444
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
445
|
+
modelId: route.modelId,
|
|
446
|
+
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
447
|
+
probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
|
|
448
|
+
writerGeneration: firstAuthCtx.writerGeneration,
|
|
449
|
+
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
450
|
+
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
455
|
+
const retryProvider = applyCodexAuthContextToProvider(
|
|
456
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
457
|
+
retryAuthCtx,
|
|
458
|
+
"pool",
|
|
459
|
+
);
|
|
460
|
+
const retryAdapter = resolveAdapter(
|
|
461
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider, inboundWire),
|
|
462
|
+
config.cacheRetention,
|
|
463
|
+
);
|
|
464
|
+
const request = await retryAdapter.buildRequest(parsed, {
|
|
465
|
+
headers: retryHeaders,
|
|
466
|
+
translatorBudget: options.translatorBudget,
|
|
467
|
+
});
|
|
468
|
+
recordAdapterReasoning(logCtx, request);
|
|
469
|
+
|
|
470
|
+
await firstResponse.body?.cancel().catch(() => undefined);
|
|
471
|
+
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
472
|
+
route.provider = retryProvider;
|
|
473
|
+
logCtx.provider = formatCodexProviderForLog(
|
|
474
|
+
route.providerName,
|
|
475
|
+
retryAuthCtx.accountId,
|
|
476
|
+
config,
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
480
|
+
try {
|
|
481
|
+
const upstreamResponse = await fetchWithHeaderTimeout(
|
|
482
|
+
request.url,
|
|
483
|
+
{
|
|
484
|
+
method: request.method,
|
|
485
|
+
headers: request.headers,
|
|
486
|
+
body: request.body,
|
|
487
|
+
},
|
|
488
|
+
upstream.signal,
|
|
489
|
+
connectMs,
|
|
490
|
+
stream,
|
|
491
|
+
providerFetch(route.provider),
|
|
492
|
+
// Credential-bearing forward send: never follow a redirect into a
|
|
493
|
+
// dead-host rejection after the credential was seen (#914).
|
|
494
|
+
route.provider.authMode === "forward",
|
|
495
|
+
);
|
|
496
|
+
// A real HTTP response proves the host was reached (#914).
|
|
497
|
+
const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
|
|
498
|
+
if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
|
|
499
|
+
resetUpstreamHostHealth(retryHostKey, null);
|
|
500
|
+
} else {
|
|
501
|
+
resetUpstreamHostHealth(retryHostKey);
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
kind: "retried",
|
|
505
|
+
authCtx: retryAuthCtx,
|
|
506
|
+
request,
|
|
507
|
+
upstreamResponse,
|
|
508
|
+
selectedForwardHeaders: retryHeaders,
|
|
509
|
+
};
|
|
510
|
+
} catch (error) {
|
|
511
|
+
// Attribute the transport failure to the alternate account (already selected).
|
|
512
|
+
return { kind: "transport", error, authCtx: retryAuthCtx };
|
|
513
|
+
} finally {
|
|
514
|
+
request.releaseBodyObservation?.();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
export function codexForwardTerminalOutcomeRecorder(
|
|
521
|
+
config: OcxConfig,
|
|
522
|
+
authCtx: CodexAuthContext,
|
|
523
|
+
provider: OcxProviderConfig,
|
|
524
|
+
modelId?: string,
|
|
525
|
+
logCtx?: RequestLogContext,
|
|
526
|
+
threadId?: string | null,
|
|
527
|
+
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
528
|
+
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
529
|
+
return (status, httpStatusOverride) => {
|
|
530
|
+
if (status === "incomplete") {
|
|
531
|
+
// Normal limit/content-filter/stall terminal — the account served the
|
|
532
|
+
// request. Don't penalize account health; record success to clear any
|
|
533
|
+
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
534
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
535
|
+
threadId,
|
|
536
|
+
fixedAccount: authCtx.fixedAccount,
|
|
537
|
+
modelId,
|
|
538
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
539
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
540
|
+
writerGeneration: authCtx.writerGeneration,
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// status === "completed" or "failed": use the semantic HTTP status derived
|
|
545
|
+
// from the terminal SSE error payload (httpStatusFromTerminalError in
|
|
546
|
+
// request-log inspection) instead of collapsing every non-completed terminal
|
|
547
|
+
// to 502. A 400 invalid_request_error must not soft-avoid the account or
|
|
548
|
+
// rebind threads — only genuine transport/5xx failures should trigger
|
|
549
|
+
// transient health recording.
|
|
550
|
+
// httpStatusOverride: the combo WS path inspects SSE payloads into the parent
|
|
551
|
+
// logCtx, but this recorder closes over the child logCtx. The caller passes
|
|
552
|
+
// the parent's terminalHttpStatus so the semantic status is not lost.
|
|
553
|
+
const outcome = status === "completed"
|
|
554
|
+
? 200
|
|
555
|
+
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
556
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
557
|
+
threadId,
|
|
558
|
+
fixedAccount: authCtx.fixedAccount,
|
|
559
|
+
modelId,
|
|
560
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
561
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
562
|
+
writerGeneration: authCtx.writerGeneration,
|
|
563
|
+
});
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
export function decodeRequestErrorResponse(err: unknown, label: string): Response {
|
|
570
|
+
if (isTranslatorBudgetExceededError(err)) {
|
|
571
|
+
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
|
|
572
|
+
code: "translation_buffer_limit",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if (err instanceof UnsupportedContentEncodingError) {
|
|
576
|
+
return formatErrorResponse(415, "invalid_request_error", err.message);
|
|
577
|
+
}
|
|
578
|
+
if (err instanceof DecompressedBodyTooLargeError) {
|
|
579
|
+
return formatErrorResponse(413, "invalid_request_error", err.message);
|
|
580
|
+
}
|
|
581
|
+
console.warn(`[${label}] request body decode/parse failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
|
|
582
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
export function comboUnavailableResponse(message: string): Response {
|
|
588
|
+
return new Response(
|
|
589
|
+
JSON.stringify({
|
|
590
|
+
error: { message, type: "server_error", code: "combo_unavailable" },
|
|
591
|
+
}),
|
|
592
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
export interface ConsumedComboFailure {
|
|
599
|
+
response: Response;
|
|
600
|
+
classificationText: string;
|
|
601
|
+
/** Structured upstream `error.code` when present in the failure body. */
|
|
602
|
+
upstreamCode?: string;
|
|
603
|
+
/** Valid numeric/date value used only for cooldown calculation. */
|
|
604
|
+
retryAfter?: string;
|
|
605
|
+
/** Reserved for 040 usage attribution without adding another body read. */
|
|
606
|
+
usage?: OcxUsage;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
export interface HandleResponsesOptions {
|
|
612
|
+
turnAdmissionLease?: AdmissionLease;
|
|
613
|
+
forceEmptyResponseId?: boolean;
|
|
614
|
+
abortSignal?: AbortSignal;
|
|
615
|
+
/** One-shot TTFT callback: first non-empty model output observed (WP4). */
|
|
616
|
+
onFirstOutput?: () => void;
|
|
617
|
+
onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
|
|
618
|
+
recordTerminalOutcomes?: boolean;
|
|
619
|
+
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
|
|
620
|
+
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
621
|
+
onNativePassthroughCancel?: () => void;
|
|
622
|
+
/**
|
|
623
|
+
* When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
|
|
624
|
+
* (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
|
|
625
|
+
*/
|
|
626
|
+
promptCacheKeyIsSharedCohort?: boolean;
|
|
627
|
+
/**
|
|
628
|
+
* Wire protocol the ORIGINAL client spoke. The Chat and Anthropic surfaces translate
|
|
629
|
+
* their body into a Responses shape and replay through this function, so without an
|
|
630
|
+
* explicit value the replay would look like a native Responses request and an
|
|
631
|
+
* inbound-scoped registry wire default would fire for a client that never asked for
|
|
632
|
+
* it. Omitted means a genuine Responses inbound.
|
|
633
|
+
*/
|
|
634
|
+
inboundWire?: InboundWire;
|
|
635
|
+
/** Internal transport identity for route-scoped upstream compatibility policy. */
|
|
636
|
+
inboundTransport?: "websocket";
|
|
637
|
+
/** Internal recursion guard; callers outside this module must not set it. */
|
|
638
|
+
comboAttempt?: boolean;
|
|
639
|
+
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
|
|
640
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
641
|
+
/** 030-owned handoff when a child consumed the original failure under bounds. */
|
|
642
|
+
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
|
|
643
|
+
/** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */
|
|
644
|
+
translatorBudget?: TranslatorBudget;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Build the 499 JSON error the proxy returns when the client disconnects before the
|
|
651
|
+
* response completes (`client_cancelled`).
|
|
652
|
+
*/
|
|
653
|
+
export function clientCancelledResponse(): Response {
|
|
654
|
+
return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
export function sanitizedRetryAfter(value: string | null, now: number): string | undefined {
|
|
660
|
+
const trimmed = value?.trim();
|
|
661
|
+
if (!trimmed || trimmed.length > 128) return undefined;
|
|
662
|
+
return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
export async function consumeComboFailure(
|
|
668
|
+
response: Response,
|
|
669
|
+
signal?: AbortSignal,
|
|
670
|
+
now = Date.now(),
|
|
671
|
+
): Promise<ConsumedComboFailure> {
|
|
672
|
+
const fallback = `Provider error ${response.status}`;
|
|
673
|
+
let classificationText = fallback;
|
|
674
|
+
let usage: OcxUsage | undefined;
|
|
675
|
+
let upstreamCode: string | undefined;
|
|
676
|
+
try {
|
|
677
|
+
const body = await readBoundedResponseBody(response, { signal });
|
|
678
|
+
usage = usageFromComboFailureText(body.text);
|
|
679
|
+
if (body.displaySafe) {
|
|
680
|
+
const safeText = redactSecretString(body.text).slice(0, 500);
|
|
681
|
+
if (safeText) classificationText = safeText;
|
|
682
|
+
try {
|
|
683
|
+
const parsed = JSON.parse(body.text) as { error?: { code?: unknown } | string };
|
|
684
|
+
const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.code : undefined;
|
|
685
|
+
if (typeof nested === "string" && nested.length > 0) upstreamCode = nested;
|
|
686
|
+
} catch {
|
|
687
|
+
/* non-JSON upstream body — message-only classification */
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (signal?.aborted) throw error;
|
|
692
|
+
classificationText = fallback;
|
|
693
|
+
}
|
|
694
|
+
const message = classificationText === fallback
|
|
695
|
+
? fallback
|
|
696
|
+
: `${fallback}: ${classificationText}`;
|
|
697
|
+
const upstreamRetryAfter = response.headers.get("retry-after");
|
|
698
|
+
// Client response may get the synthetic "2" fallback; cooldown metadata must not —
|
|
699
|
+
// otherwise coolComboTarget treats it as a 2s cooldown instead of the 60s default.
|
|
700
|
+
const clientRetryAfter = resolveClientRetryAfter({
|
|
701
|
+
status: response.status,
|
|
702
|
+
message,
|
|
703
|
+
upstreamRetryAfter,
|
|
704
|
+
now,
|
|
705
|
+
});
|
|
706
|
+
const cooldownRetryAfter = resolveClientRetryAfter({
|
|
707
|
+
status: response.status,
|
|
708
|
+
message,
|
|
709
|
+
upstreamRetryAfter,
|
|
710
|
+
now,
|
|
711
|
+
includeDefault: false,
|
|
712
|
+
});
|
|
713
|
+
return {
|
|
714
|
+
response: formatErrorResponse(response.status, "upstream_error", message, {
|
|
715
|
+
...(upstreamCode !== undefined ? { code: upstreamCode } : {}),
|
|
716
|
+
...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
|
|
717
|
+
}),
|
|
718
|
+
classificationText,
|
|
719
|
+
...(upstreamCode !== undefined ? { upstreamCode } : {}),
|
|
720
|
+
...(cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
|
|
721
|
+
...(usage ? { usage } : {}),
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
export function usageFromComboFailureText(text: string): OcxUsage | undefined {
|
|
728
|
+
try {
|
|
729
|
+
const payload = JSON.parse(text) as Record<string, unknown>;
|
|
730
|
+
const nested = payload.response;
|
|
731
|
+
const source = nested && typeof nested === "object" && !Array.isArray(nested)
|
|
732
|
+
? nested as Record<string, unknown>
|
|
733
|
+
: payload;
|
|
734
|
+
return usageFromResponsesPayload(source.usage);
|
|
735
|
+
} catch {
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
export function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
|
|
743
|
+
type Pending =
|
|
744
|
+
| { kind: "terminal"; status: ResponsesTerminalStatus }
|
|
745
|
+
| { kind: "cancel" };
|
|
746
|
+
let state: "pending" | "committed" | "discarded" = "pending";
|
|
747
|
+
let pending: Pending | undefined;
|
|
748
|
+
let accepted = false;
|
|
749
|
+
const publish = (value: Pending): void => {
|
|
750
|
+
if (value.kind === "terminal") options.onNativePassthroughTerminal?.(value.status);
|
|
751
|
+
else options.onNativePassthroughCancel?.();
|
|
752
|
+
};
|
|
753
|
+
const receive = (value: Pending): void => {
|
|
754
|
+
if (state === "discarded" || accepted) return;
|
|
755
|
+
accepted = true;
|
|
756
|
+
if (state === "committed") return publish(value);
|
|
757
|
+
pending ??= value;
|
|
758
|
+
};
|
|
759
|
+
return {
|
|
760
|
+
onTerminal: (status: ResponsesTerminalStatus) => receive({ kind: "terminal", status }),
|
|
761
|
+
onCancel: () => receive({ kind: "cancel" }),
|
|
762
|
+
commit: () => {
|
|
763
|
+
if (state !== "pending") return;
|
|
764
|
+
state = "committed";
|
|
765
|
+
if (pending) publish(pending);
|
|
766
|
+
pending = undefined;
|
|
767
|
+
},
|
|
768
|
+
discard: () => {
|
|
769
|
+
state = "discarded";
|
|
770
|
+
pending = undefined;
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
778
|
+
const childHeaders = new Headers(parentHeaders);
|
|
779
|
+
// Combo children re-serialize already-decoded JSON. Keeping transport metadata from
|
|
780
|
+
// the parent would make the child decoder treat plain JSON as compressed bytes.
|
|
781
|
+
childHeaders.delete("content-length");
|
|
782
|
+
childHeaders.delete("content-encoding");
|
|
783
|
+
return childHeaders;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
|
|
787
|
+
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.";
|
|
788
|
+
|
|
789
|
+
// Whole-body policy for non-streaming upstream JSON responses (see the application/json
|
|
790
|
+
// branch of the passthrough return path). 32 MiB matches the continuation snapshot read
|
|
791
|
+
// bound and is far above any legitimate non-streaming completion, including base64 image
|
|
792
|
+
// payloads. The stall deadlines only govern the body transfer — generation time before
|
|
793
|
+
// the response headers is untouched. Generation after early/chunked headers but before
|
|
794
|
+
// the first body byte previously used the 30-second inactivity deadline; this call site
|
|
795
|
+
// gives it the full body deadline instead.
|
|
796
|
+
const MAX_UPSTREAM_JSON_BODY_BYTES = 32 * 1024 * 1024;
|
|
797
|
+
const UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS = 180_000;
|
|
798
|
+
const UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS = 30_000;
|
|
799
|
+
export const UPSTREAM_JSON_BODY_READ_OPTIONS = {
|
|
800
|
+
maxBytes: MAX_UPSTREAM_JSON_BODY_BYTES,
|
|
801
|
+
totalTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS,
|
|
802
|
+
inactivityTimeoutMs: UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS,
|
|
803
|
+
firstByteTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS,
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
function unreadableEncryptedAgentTaskResponse(): Response {
|
|
807
|
+
return new Response(
|
|
808
|
+
JSON.stringify({
|
|
809
|
+
error: {
|
|
810
|
+
message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE,
|
|
811
|
+
type: "invalid_request_error",
|
|
812
|
+
code: "unreadable_encrypted_agent_task",
|
|
813
|
+
},
|
|
814
|
+
}),
|
|
815
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
type ResponsesAuthResolution =
|
|
820
|
+
| { ok: true; authCtx: CodexAuthContext; headers: Headers }
|
|
821
|
+
| { ok: false; response: Response };
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Resolve Codex auth for a route. On unusable contexts, releases any probe lease
|
|
825
|
+
* before returning the 401 (nothing reaches upstream).
|
|
826
|
+
*/
|
|
827
|
+
async function resolveResponsesCodexAuth(
|
|
828
|
+
req: Request,
|
|
829
|
+
config: OcxConfig,
|
|
830
|
+
route: RouteResult,
|
|
831
|
+
options: HandleResponsesOptions,
|
|
832
|
+
): Promise<ResponsesAuthResolution> {
|
|
833
|
+
try {
|
|
834
|
+
if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
|
|
835
|
+
let authCtx: CodexAuthContext;
|
|
836
|
+
if (route.codexAccountMode) {
|
|
837
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
|
|
838
|
+
accountId: route.codexAccountId,
|
|
839
|
+
modelId: route.modelId,
|
|
840
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
841
|
+
});
|
|
842
|
+
options.onCodexAuthContextResolved?.(authCtx);
|
|
843
|
+
} else {
|
|
844
|
+
authCtx = { kind: "main", accountId: null };
|
|
845
|
+
options.onCodexAuthContextResolved?.(undefined);
|
|
846
|
+
}
|
|
847
|
+
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
848
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
849
|
+
return {
|
|
850
|
+
ok: false,
|
|
851
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
ok: true,
|
|
856
|
+
authCtx,
|
|
857
|
+
headers: headersForCodexAuthContext(req.headers, authCtx),
|
|
858
|
+
};
|
|
859
|
+
} catch (err) {
|
|
860
|
+
if (err instanceof CodexAccountCooldownError) {
|
|
861
|
+
return { ok: false, response: cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace) };
|
|
862
|
+
}
|
|
863
|
+
if (err instanceof CodexMainProfileDrainingError) {
|
|
864
|
+
return { ok: false, response: codexMainProfileDrainingResponse() };
|
|
865
|
+
}
|
|
866
|
+
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
867
|
+
return {
|
|
868
|
+
ok: false,
|
|
869
|
+
response: formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"),
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
if (err instanceof CodexAuthContextError) {
|
|
873
|
+
const safeAccountLabel = route.codexAccountNamespace
|
|
874
|
+
? `${route.providerName}-${route.codexAccountNamespace}`
|
|
875
|
+
: formatCodexProviderForLog(route.providerName, err.accountId, config);
|
|
876
|
+
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
if (err instanceof CodexPoolAuthenticationError) {
|
|
883
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
884
|
+
}
|
|
885
|
+
if (err instanceof CodexDirectAuthenticationError) {
|
|
886
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
887
|
+
}
|
|
888
|
+
if (err instanceof ForwardAdmissionCredentialError) {
|
|
889
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
890
|
+
}
|
|
891
|
+
throw err;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Apply every route-dependent request mutation against the final selected route.
|
|
897
|
+
* Must run only after subagent fallback has settled the model/provider.
|
|
898
|
+
*/
|
|
899
|
+
async function applyFinalRouteRequestNormalization(args: {
|
|
900
|
+
parsed: OcxParsedRequest;
|
|
901
|
+
route: RouteResult;
|
|
902
|
+
config: OcxConfig;
|
|
903
|
+
req: Request;
|
|
904
|
+
logCtx: RequestLogContext;
|
|
905
|
+
inboundWire: InboundWire;
|
|
906
|
+
inboundTransport?: "websocket";
|
|
907
|
+
}): Promise<void> {
|
|
908
|
+
const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args;
|
|
909
|
+
|
|
910
|
+
// Only Anthropic message routes retain the Codex-facing selector. Other providers must keep
|
|
911
|
+
// their existing response.model contract even when their public and wire model ids differ.
|
|
912
|
+
const responseModelId = parsed.modelId;
|
|
913
|
+
const preserveAnthropicResponseModel = route.providerName === "anthropic"
|
|
914
|
+
|| route.provider.adapter === "anthropic";
|
|
915
|
+
|
|
916
|
+
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace.
|
|
917
|
+
if (route.modelId !== parsed.modelId) {
|
|
918
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
919
|
+
(parsed._rawBody as { model?: string }).model = route.modelId;
|
|
920
|
+
}
|
|
921
|
+
parsed.modelId = route.modelId;
|
|
922
|
+
}
|
|
923
|
+
// Transport-neutral reliability policy (#875): applies to any Responses
|
|
924
|
+
// upstream whose final adapter is openai-responses, not only WS turns.
|
|
925
|
+
const responsesUpstreamStreaming = providerModelResponsesUpstreamStreaming(
|
|
926
|
+
route.providerName,
|
|
927
|
+
route.provider,
|
|
928
|
+
route.modelId,
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
|
|
932
|
+
// this request will actually use (#404).
|
|
933
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
934
|
+
if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
|
|
935
|
+
logCtx.model = route.modelId;
|
|
936
|
+
logCtx.provider = route.providerName;
|
|
937
|
+
logCtx.providerAdapter = route.provider.adapter;
|
|
938
|
+
logCtx.routeDecision = route.routeDecision;
|
|
939
|
+
|
|
940
|
+
if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") {
|
|
941
|
+
parsed.stream = false;
|
|
942
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
943
|
+
(parsed._rawBody as Record<string, unknown>).stream = false;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Final selected model before virtual wire-model rewriting (Pro aliases).
|
|
948
|
+
const finalSelectedModelId = route.modelId;
|
|
949
|
+
|
|
950
|
+
// Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro".
|
|
951
|
+
applyOpenAiVirtualModel(parsed, route, logCtx);
|
|
952
|
+
if (parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId) {
|
|
953
|
+
logCtx.resolvedModel = route.modelId;
|
|
954
|
+
logCtx.preserveResolvedModelFromRoute = true;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// The global Fast Mode setting is a fallback for clients that did not choose a
|
|
958
|
+
// per-chat response speed. Android/Desktop's explicit Standard/Fast selection
|
|
959
|
+
// must win, while the capability gate below still strips unsupported routes.
|
|
960
|
+
const requestHasExplicitServiceTier = parsed.options.serviceTier !== undefined;
|
|
961
|
+
if (
|
|
962
|
+
!requestHasExplicitServiceTier
|
|
963
|
+
&& config.fastMode !== undefined
|
|
964
|
+
&& route.provider.adapter === "openai-responses"
|
|
965
|
+
&& route.provider.supportsServiceTier === true
|
|
966
|
+
) {
|
|
967
|
+
const tier = config.fastMode ? "priority" : undefined;
|
|
968
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
969
|
+
if (tier) (parsed._rawBody as Record<string, unknown>).service_tier = tier;
|
|
970
|
+
else delete (parsed._rawBody as Record<string, unknown>).service_tier;
|
|
971
|
+
}
|
|
972
|
+
parsed.options.serviceTier = tier;
|
|
973
|
+
}
|
|
974
|
+
applyServiceTierGate(route.provider, parsed._rawBody, parsed.options);
|
|
975
|
+
|
|
976
|
+
const reasoningNormalization = applyReasoningCapability(parsed, route);
|
|
977
|
+
if (reasoningNormalization) {
|
|
978
|
+
logCtx.requestedEffort = `${reasoningNormalization.from}->${reasoningNormalization.to}`;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
{
|
|
982
|
+
const guidance = await multiAgentGuidanceText(parsed, {
|
|
983
|
+
multiAgentGuidanceEnabled: config.multiAgentGuidanceEnabled,
|
|
984
|
+
codexAccountNamespace: route.codexAccountNamespace,
|
|
985
|
+
injectionModel: config.injectionModel,
|
|
986
|
+
injectionEffort: config.injectionEffort,
|
|
987
|
+
subagentModels: config.subagentModels,
|
|
988
|
+
subagentModelFallback: config.subagentModelFallback,
|
|
989
|
+
injectionPrompt: config.injectionPrompt,
|
|
990
|
+
});
|
|
991
|
+
if (guidance) {
|
|
992
|
+
injectDeveloperMessage(parsed, guidance);
|
|
993
|
+
if (isInjectionDebugEnabled()) {
|
|
994
|
+
injectionDebugLog(`[Remodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, guidanceEnabled=${multiAgentGuidanceEnabled(config)}, ${guidance.length} chars)`);
|
|
995
|
+
}
|
|
996
|
+
} else if (isInjectionDebugEnabled() && collabSurface(parsed) !== null) {
|
|
997
|
+
injectionDebugLog(`[Remodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
{
|
|
1002
|
+
const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy");
|
|
1003
|
+
const surface = collabSurface(parsed);
|
|
1004
|
+
if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) {
|
|
1005
|
+
const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route));
|
|
1006
|
+
if (capped) {
|
|
1007
|
+
logCtx.requestedEffort = `${capped.from}->${capped.to}`;
|
|
1008
|
+
if (isInjectionDebugEnabled()) {
|
|
1009
|
+
injectionDebugLog(`[Remodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
} else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
|
|
1013
|
+
injectionDebugLog(`[Remodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
{
|
|
1018
|
+
const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog");
|
|
1019
|
+
const clamped = shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId)
|
|
1020
|
+
? nativeEffortClamp(route.modelId, parsed.options.reasoning)
|
|
1021
|
+
: null;
|
|
1022
|
+
if (clamped) {
|
|
1023
|
+
parsed.options.reasoning = clamped;
|
|
1024
|
+
const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
|
|
1025
|
+
if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = clamped;
|
|
1026
|
+
logCtx.requestedEffort = `${logCtx.requestedEffort ?? "max"}->${clamped}`;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
recordAttemptRequestedEffort(logCtx);
|
|
1030
|
+
logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
|
|
1031
|
+
route.modelId,
|
|
1032
|
+
logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
export async function handleComboResponses(
|
|
1039
|
+
req: Request,
|
|
1040
|
+
rawBody: unknown,
|
|
1041
|
+
comboId: string,
|
|
1042
|
+
config: OcxConfig,
|
|
1043
|
+
logCtx: RequestLogContext,
|
|
1044
|
+
options: HandleResponsesOptions,
|
|
1045
|
+
): Promise<Response> {
|
|
1046
|
+
const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string"
|
|
1047
|
+
? (rawBody as { model: string }).model
|
|
1048
|
+
: `combo/${comboId}`;
|
|
1049
|
+
Object.assign(logCtx, {
|
|
1050
|
+
requestedModel,
|
|
1051
|
+
model: requestedModel,
|
|
1052
|
+
provider: "combo",
|
|
1053
|
+
comboId,
|
|
1054
|
+
});
|
|
1055
|
+
const combo = getCombo(config, comboId);
|
|
1056
|
+
if (!combo) {
|
|
1057
|
+
return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
|
|
1058
|
+
}
|
|
1059
|
+
const adoptFailedChildLog = (childLog: RequestLogContext): void => {
|
|
1060
|
+
// Attempts remain the complete physical history; the logical row mirrors the most recent
|
|
1061
|
+
// failed target so an exhausted combo still has useful top-level reasoning diagnostics.
|
|
1062
|
+
Object.assign(logCtx, childLog, {
|
|
1063
|
+
requestedModel,
|
|
1064
|
+
model: requestedModel,
|
|
1065
|
+
provider: "combo",
|
|
1066
|
+
comboId,
|
|
1067
|
+
routeDecision: logCtx.routeDecision,
|
|
1068
|
+
attempts: logCtx.attempts,
|
|
1069
|
+
activeAttempt: undefined,
|
|
1070
|
+
activeAttemptStartedAt: undefined,
|
|
1071
|
+
});
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1075
|
+
(rawBody as { input?: unknown } | undefined)?.input,
|
|
1076
|
+
);
|
|
1077
|
+
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
|
|
1078
|
+
const provider = config.providers[target.provider];
|
|
1079
|
+
if (!provider || provider.disabled === true) return false;
|
|
1080
|
+
try {
|
|
1081
|
+
const route = routeModel(config, `${target.provider}/${target.model}`);
|
|
1082
|
+
return isCanonicalOpenAiForwardProvider(route.provider);
|
|
1083
|
+
} catch {
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
|
|
1088
|
+
!unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
|
|
1089
|
+
|
|
1090
|
+
if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) {
|
|
1091
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const initialNow = Date.now();
|
|
1095
|
+
let pick = pickComboTarget(config, comboId, {
|
|
1096
|
+
eligible: target => payloadEligible(target)
|
|
1097
|
+
&& !isComboTargetInCooldown(comboId, target, initialNow),
|
|
1098
|
+
});
|
|
1099
|
+
if (!pick) {
|
|
1100
|
+
return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
|
|
1101
|
+
}
|
|
1102
|
+
// One immutable combo selection trace, before any child dispatch; child
|
|
1103
|
+
// adoption below must never replace it with a concrete child route trace.
|
|
1104
|
+
logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
|
|
1105
|
+
|
|
1106
|
+
let lastFailure: Response | null = null;
|
|
1107
|
+
while (pick) {
|
|
1108
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1109
|
+
const childLog: RequestLogContext = {
|
|
1110
|
+
model: pick.target.model,
|
|
1111
|
+
provider: pick.target.provider,
|
|
1112
|
+
...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
|
|
1113
|
+
...(logCtx.surface ? { surface: logCtx.surface } : {}),
|
|
1114
|
+
};
|
|
1115
|
+
const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
1116
|
+
const childBody = concreteComboRequestBody(
|
|
1117
|
+
rawBody,
|
|
1118
|
+
pick.target,
|
|
1119
|
+
comboDefaultEffort(config, comboId),
|
|
1120
|
+
supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
|
|
1121
|
+
);
|
|
1122
|
+
const childHeaders = buildComboChildHeaders(req.headers);
|
|
1123
|
+
const childRequest = new Request(req.url, {
|
|
1124
|
+
method: req.method,
|
|
1125
|
+
headers: childHeaders,
|
|
1126
|
+
body: JSON.stringify(childBody),
|
|
1127
|
+
});
|
|
1128
|
+
let resolvedAuth: CodexAuthContext | undefined;
|
|
1129
|
+
let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
|
|
1130
|
+
const started = Date.now();
|
|
1131
|
+
const attempt = beginRequestAttempt(
|
|
1132
|
+
(logCtx.attempts?.length ?? 0) + 1,
|
|
1133
|
+
pick.target.provider,
|
|
1134
|
+
pick.target.model,
|
|
1135
|
+
config.providers[pick.target.provider]!.adapter,
|
|
1136
|
+
);
|
|
1137
|
+
childLog.activeAttempt = attempt;
|
|
1138
|
+
let attemptRetained = false;
|
|
1139
|
+
const retainCancelledAttempt = (): void => {
|
|
1140
|
+
if (attemptRetained) return;
|
|
1141
|
+
sealRequestAttemptIdentity(
|
|
1142
|
+
attempt,
|
|
1143
|
+
childLog.provider,
|
|
1144
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
1145
|
+
);
|
|
1146
|
+
finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
|
|
1147
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
1148
|
+
attemptRetained = true;
|
|
1149
|
+
};
|
|
1150
|
+
let consumedChildFailure: ConsumedComboFailure | undefined;
|
|
1151
|
+
const callbackGate = createChildPassthroughCallbackGate(options);
|
|
1152
|
+
let response: Response;
|
|
1153
|
+
try {
|
|
1154
|
+
const currentTargetProvider = pick.target.provider;
|
|
1155
|
+
const deferCodexResetDerivedCooldown = combo.strategy === "failover"
|
|
1156
|
+
&& combo.targets.slice(pick.targetIndex + 1).some(target =>
|
|
1157
|
+
target.provider === currentTargetProvider
|
|
1158
|
+
&& payloadEligible(target)
|
|
1159
|
+
&& !isComboTargetInCooldown(comboId, target),
|
|
1160
|
+
);
|
|
1161
|
+
response = await handleResponses(childRequest, config, childLog, {
|
|
1162
|
+
...options,
|
|
1163
|
+
comboAttempt: true,
|
|
1164
|
+
deferCodexResetDerivedCooldown,
|
|
1165
|
+
// Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
|
|
1166
|
+
// Object.assign(logCtx, childLog) would overwrite the request-relative value).
|
|
1167
|
+
onFirstOutput: () => {
|
|
1168
|
+
if (attempt.firstOutputMs === undefined) {
|
|
1169
|
+
attempt.firstOutputMs = Math.max(0, Date.now() - started);
|
|
1170
|
+
}
|
|
1171
|
+
options.onFirstOutput?.();
|
|
1172
|
+
},
|
|
1173
|
+
onCodexAuthContextResolved: value => { resolvedAuth = value; },
|
|
1174
|
+
setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
|
|
1175
|
+
onConsumedComboFailure: value => { consumedChildFailure = value; },
|
|
1176
|
+
onNativePassthroughTerminal: callbackGate.onTerminal,
|
|
1177
|
+
onNativePassthroughCancel: callbackGate.onCancel,
|
|
1178
|
+
});
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
callbackGate.discard();
|
|
1181
|
+
if (options.abortSignal?.aborted) {
|
|
1182
|
+
retainCancelledAttempt();
|
|
1183
|
+
return clientCancelledResponse();
|
|
1184
|
+
}
|
|
1185
|
+
throw error;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (options.abortSignal?.aborted) {
|
|
1189
|
+
callbackGate.discard();
|
|
1190
|
+
retainCancelledAttempt();
|
|
1191
|
+
return clientCancelledResponse();
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (response.ok) {
|
|
1195
|
+
sealRequestAttemptIdentity(
|
|
1196
|
+
attempt,
|
|
1197
|
+
childLog.provider,
|
|
1198
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
1199
|
+
);
|
|
1200
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
1201
|
+
attemptRetained = true;
|
|
1202
|
+
noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
|
|
1203
|
+
Object.assign(logCtx, childLog, {
|
|
1204
|
+
requestedModel,
|
|
1205
|
+
model: requestedModel,
|
|
1206
|
+
provider: "combo",
|
|
1207
|
+
comboId,
|
|
1208
|
+
routeDecision: logCtx.routeDecision,
|
|
1209
|
+
attempts: logCtx.attempts,
|
|
1210
|
+
activeAttempt: attempt,
|
|
1211
|
+
activeAttemptStartedAt: started,
|
|
1212
|
+
resolvedModel: childLog.resolvedModel ?? childLog.model,
|
|
1213
|
+
});
|
|
1214
|
+
options.onCodexAuthContextResolved?.(resolvedAuth);
|
|
1215
|
+
options.setTerminalOutcomeRecorder?.(terminalRecorder);
|
|
1216
|
+
callbackGate.commit();
|
|
1217
|
+
return response;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
callbackGate.discard();
|
|
1221
|
+
if (response.status === 499) {
|
|
1222
|
+
retainCancelledAttempt();
|
|
1223
|
+
return clientCancelledResponse();
|
|
1224
|
+
}
|
|
1225
|
+
let failure: ConsumedComboFailure;
|
|
1226
|
+
try {
|
|
1227
|
+
failure = consumedChildFailure
|
|
1228
|
+
?? await consumeComboFailure(response, options.abortSignal);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
if (options.abortSignal?.aborted) {
|
|
1231
|
+
retainCancelledAttempt();
|
|
1232
|
+
return clientCancelledResponse();
|
|
1233
|
+
}
|
|
1234
|
+
throw error;
|
|
1235
|
+
}
|
|
1236
|
+
if (options.abortSignal?.aborted) {
|
|
1237
|
+
retainCancelledAttempt();
|
|
1238
|
+
return clientCancelledResponse();
|
|
1239
|
+
}
|
|
1240
|
+
sealRequestAttemptIdentity(
|
|
1241
|
+
attempt,
|
|
1242
|
+
childLog.provider,
|
|
1243
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
1244
|
+
);
|
|
1245
|
+
finishRequestAttempt(
|
|
1246
|
+
attempt,
|
|
1247
|
+
response.status,
|
|
1248
|
+
Date.now() - started,
|
|
1249
|
+
failure.usage,
|
|
1250
|
+
);
|
|
1251
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
1252
|
+
attemptRetained = true;
|
|
1253
|
+
lastFailure = failure.response;
|
|
1254
|
+
if (comboFailureDecision(failure.response.status, failure.classificationText, {
|
|
1255
|
+
code: failure.upstreamCode,
|
|
1256
|
+
}) === "stop") {
|
|
1257
|
+
adoptFailedChildLog(childLog);
|
|
1258
|
+
return lastFailure;
|
|
1259
|
+
}
|
|
1260
|
+
console.warn(
|
|
1261
|
+
`[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`,
|
|
1262
|
+
);
|
|
1263
|
+
const nextPick = advanceComboAfterFailure(config, pick, {
|
|
1264
|
+
retryAfter: failure.retryAfter,
|
|
1265
|
+
now: Date.now(),
|
|
1266
|
+
eligible: payloadEligible,
|
|
1267
|
+
});
|
|
1268
|
+
if (!nextPick) adoptFailedChildLog(childLog);
|
|
1269
|
+
pick = nextPick;
|
|
1270
|
+
}
|
|
1271
|
+
return lastFailure!;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
function finalizeOwnedTranslatorBudget(response: Response, budget: TranslatorBudget): Response {
|
|
1277
|
+
if (!response.body) {
|
|
1278
|
+
budget.dispose();
|
|
1279
|
+
return response;
|
|
1280
|
+
}
|
|
1281
|
+
const reader = response.body.getReader();
|
|
1282
|
+
let finalized = false;
|
|
1283
|
+
const finalize = () => {
|
|
1284
|
+
if (finalized) return;
|
|
1285
|
+
finalized = true;
|
|
1286
|
+
budget.dispose();
|
|
1287
|
+
};
|
|
1288
|
+
const body = new ReadableStream<Uint8Array>({
|
|
1289
|
+
async pull(controller) {
|
|
1290
|
+
try {
|
|
1291
|
+
const result = await reader.read();
|
|
1292
|
+
if (result.done) {
|
|
1293
|
+
finalize();
|
|
1294
|
+
controller.close();
|
|
1295
|
+
} else {
|
|
1296
|
+
controller.enqueue(result.value);
|
|
1297
|
+
}
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
finalize();
|
|
1300
|
+
controller.error(error);
|
|
1301
|
+
}
|
|
1302
|
+
},
|
|
1303
|
+
async cancel(reason) {
|
|
1304
|
+
try { await reader.cancel(reason); } finally { finalize(); }
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
const finalizedResponse = new Response(body, {
|
|
1308
|
+
status: response.status,
|
|
1309
|
+
statusText: response.statusText,
|
|
1310
|
+
headers: response.headers,
|
|
1311
|
+
});
|
|
1312
|
+
if (isNativePassthroughSseResponse(response)) {
|
|
1313
|
+
markNativePassthroughSseResponse(finalizedResponse);
|
|
1314
|
+
}
|
|
1315
|
+
if (isEagerRelaySseResponse(response)) {
|
|
1316
|
+
markEagerRelaySseResponse(finalizedResponse);
|
|
1317
|
+
}
|
|
1318
|
+
return finalizedResponse;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* Service-tier capability gate, applied after the final route/wire is settled. A
|
|
1323
|
+
* provider explicitly documented as NOT supporting `service_tier` must never
|
|
1324
|
+
* receive it: strip the field and clear the logging value even when the caller
|
|
1325
|
+
* supplied one (fail closed). Tri-state contract: `true` supports (injection
|
|
1326
|
+
* allowed, caller values preserved), `false` strips, and an UNCLASSIFIED custom
|
|
1327
|
+
* provider (`undefined`) preserves caller-supplied values but never gets an
|
|
1328
|
+
* injection — deleting the caller's field there would silently change their
|
|
1329
|
+
* request against a gateway we know nothing about.
|
|
1330
|
+
*/
|
|
1331
|
+
export function applyServiceTierGate(
|
|
1332
|
+
provider: OcxProviderConfig,
|
|
1333
|
+
rawBody: unknown,
|
|
1334
|
+
options: { serviceTier?: string },
|
|
1335
|
+
): void {
|
|
1336
|
+
if (provider.adapter !== "openai-responses" || provider.supportsServiceTier !== false) return;
|
|
1337
|
+
if (rawBody && typeof rawBody === "object") {
|
|
1338
|
+
delete (rawBody as Record<string, unknown>).service_tier;
|
|
1339
|
+
}
|
|
1340
|
+
options.serviceTier = undefined;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
|
|
1345
|
+
* wire, image/web-search bridges, and the terminal-guard continuation.
|
|
1346
|
+
*/
|
|
1347
|
+
export async function handleResponses(
|
|
1348
|
+
req: Request,
|
|
1349
|
+
config: OcxConfig,
|
|
1350
|
+
logCtx: RequestLogContext,
|
|
1351
|
+
options: HandleResponsesOptions = {},
|
|
1352
|
+
): Promise<Response> {
|
|
1353
|
+
const ownsBudget = options.translatorBudget === undefined;
|
|
1354
|
+
const translatorBudget = options.translatorBudget ?? createTranslatorBudget();
|
|
1355
|
+
try {
|
|
1356
|
+
const response = await handleResponsesInner(req, config, logCtx, { ...options, translatorBudget });
|
|
1357
|
+
return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response;
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
if (ownsBudget) translatorBudget.dispose();
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Inner implementation of `handleResponses`; owns the pre-stream recovery loop and the
|
|
1366
|
+
* per-request same-target 429 retry budgets.
|
|
1367
|
+
*/
|
|
1368
|
+
async function handleResponsesInner(
|
|
1369
|
+
req: Request,
|
|
1370
|
+
config: OcxConfig,
|
|
1371
|
+
logCtx: RequestLogContext,
|
|
1372
|
+
options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
|
|
1373
|
+
): Promise<Response> {
|
|
1374
|
+
let pendingHostAdmissionLease: UpstreamHostAdmissionLease | null = null;
|
|
1375
|
+
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
1376
|
+
try {
|
|
1377
|
+
// The Chat and Anthropic surfaces replay through here with a Responses-shaped body,
|
|
1378
|
+
// so an omitted value means a genuine Responses inbound.
|
|
1379
|
+
const inboundWire = options.inboundWire ?? "responses";
|
|
1380
|
+
const translatorBudget = options.translatorBudget;
|
|
1381
|
+
let body: unknown;
|
|
1382
|
+
try {
|
|
1383
|
+
body = await readJsonRequestBody(req, translatorBudget);
|
|
1384
|
+
} catch (err) {
|
|
1385
|
+
return decodeRequestErrorResponse(err, "responses");
|
|
1386
|
+
}
|
|
1387
|
+
const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
|
|
1388
|
+
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
1389
|
+
return handleComboResponses(req, body, comboId, config, logCtx, options);
|
|
1390
|
+
}
|
|
1391
|
+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1392
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
1393
|
+
);
|
|
1394
|
+
const originalBody = body;
|
|
1395
|
+
body = expandPreviousResponseInput(body);
|
|
1396
|
+
if (previousResponseReplayFailure(body)) {
|
|
1397
|
+
return formatErrorResponse(
|
|
1398
|
+
400,
|
|
1399
|
+
"previous_response_not_found",
|
|
1400
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
const previousResponseInputExpanded = body !== originalBody;
|
|
1404
|
+
|
|
1405
|
+
// Spawn-message compatibility (both directions): agent_message task payloads ride in
|
|
1406
|
+
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
1407
|
+
// parsing so every consumer sees the payload: parseRequest (routed/translated providers read
|
|
1408
|
+
// the parsed messages) and the native passthrough (_rawBody is this same object, serialized
|
|
1409
|
+
// verbatim). Genuine backend ciphertext is left byte-identical (looksLikeBackendCiphertext).
|
|
1410
|
+
{
|
|
1411
|
+
const rewritten = sanitizeEncryptedContentInPlace(
|
|
1412
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
1413
|
+
);
|
|
1414
|
+
if (rewritten > 0)
|
|
1415
|
+
console.warn(
|
|
1416
|
+
`[Remodex] rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (spawn-message compatibility)`,
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
let parsed;
|
|
1421
|
+
let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
|
|
1422
|
+
try {
|
|
1423
|
+
parsed = parseRequest(body);
|
|
1424
|
+
toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
|
|
1425
|
+
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
1426
|
+
parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
|
|
1427
|
+
parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
|
|
1428
|
+
const clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim();
|
|
1429
|
+
if (clientThreadId) parsed._clientThreadId = clientThreadId;
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
if (isTranslatorBudgetExceededError(err)) {
|
|
1432
|
+
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
|
|
1433
|
+
code: "translation_buffer_limit",
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1437
|
+
}
|
|
1438
|
+
// Prefer a pre-populated id (routed Claude) over Responses headers that may be
|
|
1439
|
+
// absent or synthetically injected (session_id from prompt_cache_key).
|
|
1440
|
+
if (!logCtx.conversationId) {
|
|
1441
|
+
logCtx.conversationId = conversationIdFromResponsesRequest({
|
|
1442
|
+
clientThreadId: parsed._clientThreadId,
|
|
1443
|
+
sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
|
|
1444
|
+
threadIdHeader: req.headers.get("thread-id"),
|
|
1445
|
+
cursorConversationId: parsed._cursorConversationId,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
logCtx.requestedModel = parsed.modelId;
|
|
1449
|
+
logCtx.requestedEffort = parsed.options.reasoning;
|
|
1450
|
+
logCtx.requestedServiceTier = parsed.options.serviceTier;
|
|
1451
|
+
logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
|
|
1452
|
+
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
1453
|
+
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
1454
|
+
|
|
1455
|
+
// Shadow call intercept: rewrite Codex 0.145.0+ helper calls (gpt-5.6-luna).
|
|
1456
|
+
// Ancient clients using gpt-5.4-mini remain configurable via sourceModels.
|
|
1457
|
+
const _sci = config.shadowCallIntercept;
|
|
1458
|
+
if (_sci?.enabled && _sci.model && shouldInterceptShadowCall(
|
|
1459
|
+
parsed.modelId,
|
|
1460
|
+
_sci.sourceModels,
|
|
1461
|
+
req.headers,
|
|
1462
|
+
)) {
|
|
1463
|
+
const _sciOriginal = parsed.modelId;
|
|
1464
|
+
parsed.modelId = _sci.model;
|
|
1465
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1466
|
+
(parsed._rawBody as { model?: string }).model = _sci.model;
|
|
1467
|
+
}
|
|
1468
|
+
// Force effort to low for shadow/helper calls (matching upstream behavior)
|
|
1469
|
+
parsed.options.reasoning = "low";
|
|
1470
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1471
|
+
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
1472
|
+
}
|
|
1473
|
+
(logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
|
|
1474
|
+
// Helpers must not resume/append into the parent thread's Cursor conversation.
|
|
1475
|
+
parsed._cursorIsolateConversation = true;
|
|
1476
|
+
}
|
|
1477
|
+
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
1478
|
+
|
|
1479
|
+
let route: RouteResult;
|
|
1480
|
+
try {
|
|
1481
|
+
route = routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
|
|
1482
|
+
logCtx.routeDecision = route.routeDecision;
|
|
1483
|
+
} catch (err) {
|
|
1484
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
1485
|
+
return comboUnavailableResponse(err.message);
|
|
1486
|
+
}
|
|
1487
|
+
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
1488
|
+
// Persist the evaluation trace (per-candidate exclusions + the
|
|
1489
|
+
// no-eligible reason) so failed policy requests stay auditable.
|
|
1490
|
+
logCtx.routeDecision = err.trace;
|
|
1491
|
+
}
|
|
1492
|
+
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
|
|
1496
|
+
&& parsed._previousResponseInputExpanded !== true;
|
|
1497
|
+
// Exact account selectors are isolated from Pool-wide quota work. A canonical replay miss must
|
|
1498
|
+
// also fail closed without polling quota upstream. Cached fallback state can still select a
|
|
1499
|
+
// provider with native continuation support below.
|
|
1500
|
+
const threadSpawn = isThreadSpawnRequest(req.headers);
|
|
1501
|
+
const previewSelectionAdmission = threadSpawn && route.codexAccountId === undefined
|
|
1502
|
+
? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
|
|
1503
|
+
: undefined;
|
|
1504
|
+
const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
|
|
1505
|
+
const nativeMainReadsForbidden = nativeMainRecoveryBlocked
|
|
1506
|
+
|| previewSelectionAdmission?.mainProfileDraining === true;
|
|
1507
|
+
const previewSelectionOptions = {
|
|
1508
|
+
nativeMainSelectionOnly: !nativeMainRecoveryBlocked
|
|
1509
|
+
&& previewSelectionAdmission?.mainProfileDraining === true,
|
|
1510
|
+
};
|
|
1511
|
+
let selectedForwardHeaders = req.headers;
|
|
1512
|
+
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
1513
|
+
let subagentQuotaFailureModel = parsed.modelId;
|
|
1514
|
+
|
|
1515
|
+
try {
|
|
1516
|
+
if (
|
|
1517
|
+
threadSpawn
|
|
1518
|
+
&& route.codexAccountId === undefined
|
|
1519
|
+
&& !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
|
|
1520
|
+
) {
|
|
1521
|
+
await maybePrimeSubagentQuota(config, Date.now(), { nativeMainReadsForbidden });
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Subagent fallback must settle the final model/provider BEFORE route-dependent
|
|
1525
|
+
// normalization (virtual models, effort caps, service tier, wire protocol).
|
|
1526
|
+
// Preview the preferred Codex account without acquiring a probe lease or refreshing
|
|
1527
|
+
// tokens — auth is resolved only after the final route is selected.
|
|
1528
|
+
if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) {
|
|
1529
|
+
const threadId = req.headers.get("x-codex-parent-thread-id");
|
|
1530
|
+
const previewAccountId = previewCodexAccountForRequest(
|
|
1531
|
+
threadId,
|
|
1532
|
+
config,
|
|
1533
|
+
Date.now(),
|
|
1534
|
+
undefined,
|
|
1535
|
+
previewSelectionOptions,
|
|
1536
|
+
);
|
|
1537
|
+
subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
|
|
1538
|
+
const fallback = applySubagentModelFallback(
|
|
1539
|
+
parsed,
|
|
1540
|
+
req.headers,
|
|
1541
|
+
config,
|
|
1542
|
+
previewAccountId,
|
|
1543
|
+
Date.now(),
|
|
1544
|
+
unreadableEncryptedAgentTask,
|
|
1545
|
+
previewSelectionOptions,
|
|
1546
|
+
);
|
|
1547
|
+
if (fallback) {
|
|
1548
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1549
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
1550
|
+
if (isInjectionDebugEnabled()) {
|
|
1551
|
+
injectionDebugLog(`[Remodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
|
|
1555
|
+
|
|
1556
|
+
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
1557
|
+
try {
|
|
1558
|
+
route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
|
|
1559
|
+
logCtx.routeDecision = route.routeDecision;
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
1562
|
+
return comboUnavailableResponse(err.message);
|
|
1563
|
+
}
|
|
1564
|
+
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
1565
|
+
logCtx.routeDecision = err.trace;
|
|
1566
|
+
}
|
|
1567
|
+
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
} finally {
|
|
1572
|
+
previewSelectionAdmission?.release();
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Encrypted child tasks may only reach the canonical native backend. This check
|
|
1576
|
+
// runs against the FINAL route so native-only fallback can rescue a routed primary.
|
|
1577
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
|
|
1578
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
|
|
1582
|
+
// safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
|
|
1583
|
+
// I/O instead of stripping the id and silently forwarding a context-free delta (#702).
|
|
1584
|
+
if (
|
|
1585
|
+
hasUnexpandedPreviousResponse
|
|
1586
|
+
&& isCanonicalOpenAiForwardProvider(route.provider)
|
|
1587
|
+
) {
|
|
1588
|
+
return formatErrorResponse(
|
|
1589
|
+
400,
|
|
1590
|
+
"invalid_request_error",
|
|
1591
|
+
"OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Captured before normalization: whether the CLIENT asked for SSE. The
|
|
1596
|
+
// transport-neutral upstream-streaming policy below may force a bounded JSON
|
|
1597
|
+
// upstream for reliability (#875); the answer must then be reframed to SSE
|
|
1598
|
+
// for streaming clients.
|
|
1599
|
+
const clientRequestedStream = parsed.stream;
|
|
1600
|
+
await applyFinalRouteRequestNormalization({
|
|
1601
|
+
parsed,
|
|
1602
|
+
route,
|
|
1603
|
+
config,
|
|
1604
|
+
req,
|
|
1605
|
+
logCtx,
|
|
1606
|
+
inboundWire,
|
|
1607
|
+
inboundTransport: options.inboundTransport,
|
|
1608
|
+
});
|
|
1609
|
+
// Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
|
|
1610
|
+
// the normal post-resolution provider label is assigned.
|
|
1611
|
+
if (route.codexAccountNamespace) {
|
|
1612
|
+
logCtx.provider = `${route.providerName}-${route.codexAccountNamespace}`;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1616
|
+
const preAuthHostKey = preAuthUpstreamHostCircuitKey(route, config);
|
|
1617
|
+
if (preAuthHostKey) {
|
|
1618
|
+
const admission = acquireUpstreamHostAdmission(
|
|
1619
|
+
preAuthHostKey,
|
|
1620
|
+
config.upstreamHostCircuitThreshold,
|
|
1621
|
+
);
|
|
1622
|
+
if (admission.kind === "blocked") {
|
|
1623
|
+
return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds);
|
|
1624
|
+
}
|
|
1625
|
+
pendingHostAdmissionLease = admission.lease;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
{
|
|
1629
|
+
const finalAuth = await resolveResponsesCodexAuth(req, config, route, options);
|
|
1630
|
+
if (!finalAuth.ok) return finalAuth.response;
|
|
1631
|
+
authCtx = finalAuth.authCtx;
|
|
1632
|
+
selectedForwardHeaders = finalAuth.headers;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
1636
|
+
logCtx.provider = route.codexAccountNamespace
|
|
1637
|
+
? `${route.providerName}-${route.codexAccountNamespace}`
|
|
1638
|
+
: formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
1639
|
+
// Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without
|
|
1640
|
+
// codexAccountMode still get a credential-derived scope inside the Cursor adapter.
|
|
1641
|
+
const identityScope = codexLogAccountId(authCtx);
|
|
1642
|
+
if (identityScope) parsed._cursorIdentityScope = identityScope;
|
|
1643
|
+
subagentFallbackAccountId = authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
1644
|
+
? authCtx.accountId
|
|
1645
|
+
: config.activeCodexAccountId ?? null;
|
|
1646
|
+
|
|
1647
|
+
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
1648
|
+
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
1649
|
+
const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
|
|
1650
|
+
&& route.provider.authMode === "oauth";
|
|
1651
|
+
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
1652
|
+
let anthropicPoolAccountId: string | null = null;
|
|
1653
|
+
let anthropicPoolFailovers = 0;
|
|
1654
|
+
const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
|
|
1655
|
+
? anthropicSessionKeyFromParts({
|
|
1656
|
+
sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
|
|
1657
|
+
threadIdHeader: req.headers.get("thread-id"),
|
|
1658
|
+
promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null,
|
|
1659
|
+
clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null,
|
|
1660
|
+
promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true,
|
|
1661
|
+
})
|
|
1662
|
+
: null;
|
|
1663
|
+
if (route.provider.authMode === "oauth") {
|
|
1664
|
+
try {
|
|
1665
|
+
if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
|
|
1666
|
+
const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config);
|
|
1667
|
+
if (!selection.accountId) {
|
|
1668
|
+
if (selection.reason === "all-cooled") {
|
|
1669
|
+
const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
|
|
1670
|
+
return formatErrorResponse(
|
|
1671
|
+
429,
|
|
1672
|
+
"rate_limit_error",
|
|
1673
|
+
"All Anthropic OAuth accounts are temporarily rate-limited",
|
|
1674
|
+
retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
|
|
1678
|
+
}
|
|
1679
|
+
const accessToken = await getAnthropicPoolAccessToken(selection.accountId);
|
|
1680
|
+
anthropicPoolAccountId = selection.accountId;
|
|
1681
|
+
bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId);
|
|
1682
|
+
promoteAnthropicActiveAccount(selection.accountId);
|
|
1683
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
1684
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
|
|
1685
|
+
} else {
|
|
1686
|
+
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
1687
|
+
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
1688
|
+
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
1689
|
+
if (route.providerName === "kiro") {
|
|
1690
|
+
// `{}` is intentional: this is an account-scoped request with no stored routing metadata.
|
|
1691
|
+
// Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
|
|
1692
|
+
parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
|
|
1693
|
+
}
|
|
1694
|
+
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
1695
|
+
// CCA envelope. Keep it paired with the token snapshot so an account rotation cannot mix
|
|
1696
|
+
// a fresh token with project metadata re-read from a different credential generation.
|
|
1697
|
+
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
1698
|
+
const projectId = resolved.projectId;
|
|
1699
|
+
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
if (err instanceof UnsupportedOAuthProviderError) {
|
|
1704
|
+
return formatErrorResponse(
|
|
1705
|
+
400,
|
|
1706
|
+
"invalid_request_error",
|
|
1707
|
+
`${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`,
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
route.provider = resolveProviderTransport(
|
|
1714
|
+
route.providerName,
|
|
1715
|
+
route.provider,
|
|
1716
|
+
parsed.options.promptCacheKey,
|
|
1717
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
1718
|
+
);
|
|
1719
|
+
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
1720
|
+
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
1721
|
+
logCtx.providerAdapter = adapter.name;
|
|
1722
|
+
// Ordinary requests receive one durable attempt only after their final initial
|
|
1723
|
+
// adapter is resolved. Combo children own their attempt and retries keep it.
|
|
1724
|
+
if (!options.comboAttempt && !logCtx.activeAttempt) {
|
|
1725
|
+
const attempt = beginRequestAttempt(
|
|
1726
|
+
(logCtx.attempts?.length ?? 0) + 1,
|
|
1727
|
+
logCtx.provider,
|
|
1728
|
+
route.modelId,
|
|
1729
|
+
adapter.name,
|
|
1730
|
+
);
|
|
1731
|
+
logCtx.activeAttempt = attempt;
|
|
1732
|
+
logCtx.activeAttemptStartedAt = Date.now();
|
|
1733
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
1734
|
+
}
|
|
1735
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name);
|
|
1736
|
+
const isPassthrough = "passthrough" in adapter && !!adapter.passthrough;
|
|
1737
|
+
|
|
1738
|
+
if (adapter.name === "kiro" && parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
1739
|
+
return formatErrorResponse(
|
|
1740
|
+
400,
|
|
1741
|
+
"invalid_request_error",
|
|
1742
|
+
"Kiro continuation state is missing; start a new session instead of reusing this previous_response_id.",
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
|
|
1747
|
+
const needsOpenAiVision = shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed);
|
|
1748
|
+
const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough);
|
|
1749
|
+
if (needsOpenAiVision || needsOpenAiSearch) {
|
|
1750
|
+
try {
|
|
1751
|
+
openAiSidecar = await resolveFirstUsableOpenAiSidecar(
|
|
1752
|
+
listOpenAiForwardSidecarCandidates(config),
|
|
1753
|
+
req.headers,
|
|
1754
|
+
config,
|
|
1755
|
+
{
|
|
1756
|
+
// Account-qualified native routes are passthrough, so their in-turn helper is vision.
|
|
1757
|
+
// Scope its cooldown and outcome to the helper model, not the routed text model.
|
|
1758
|
+
...(route.codexAccountId !== undefined
|
|
1759
|
+
? { exactAccount: { accountId: route.codexAccountId, modelId: resolveOpenAiVisionModel(config) } }
|
|
1760
|
+
: {}),
|
|
1761
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
1762
|
+
},
|
|
1763
|
+
);
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
// Sidecars are optional helpers for an otherwise independent routed turn.
|
|
1766
|
+
// An unavailable/cooling/expired Multi credential disables the helper; it
|
|
1767
|
+
// must not turn a valid routed-provider request into a Codex-auth failure.
|
|
1768
|
+
if (
|
|
1769
|
+
!(err instanceof CodexPoolAuthenticationError)
|
|
1770
|
+
&& !(err instanceof CodexAuthContextError)
|
|
1771
|
+
&& !(err instanceof CodexAccountCooldownError)
|
|
1772
|
+
&& !(err instanceof CodexThreadAffinityExpiredError)
|
|
1773
|
+
&& !(err instanceof CodexMainProfileDrainingError)
|
|
1774
|
+
) throw err;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
1779
|
+
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
1780
|
+
// call, so the text-only model can reason about it.
|
|
1781
|
+
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
|
|
1782
|
+
const recordSidecarOutcome = openAiSidecar?.recordOutcome;
|
|
1783
|
+
if (visionPlan) {
|
|
1784
|
+
await describeImagesInPlace(
|
|
1785
|
+
parsed,
|
|
1786
|
+
visionPlan,
|
|
1787
|
+
openAiSidecar?.headers ?? selectedForwardHeaders,
|
|
1788
|
+
options.abortSignal,
|
|
1789
|
+
recordSidecarOutcome,
|
|
1790
|
+
translatorBudget,
|
|
1791
|
+
);
|
|
1792
|
+
} else if (modelInList(route.provider.noVisionModels, route.modelId)) {
|
|
1793
|
+
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
1794
|
+
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
1795
|
+
stripImagesInPlace(parsed, translatorBudget);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
|
|
1799
|
+
|
|
1800
|
+
const continuationStateForResponse = (
|
|
1801
|
+
emitted?: OcxProviderContinuationState,
|
|
1802
|
+
): OcxProviderContinuationState | undefined => {
|
|
1803
|
+
const cursorConversationId = parsed._cursorConversationId;
|
|
1804
|
+
const inherited = parsed._providerContinuation;
|
|
1805
|
+
if (!emitted && !inherited && !cursorConversationId) return undefined;
|
|
1806
|
+
return {
|
|
1807
|
+
...(inherited ?? {}),
|
|
1808
|
+
...(emitted ?? {}),
|
|
1809
|
+
...((inherited?.kiro || emitted?.kiro)
|
|
1810
|
+
? { kiro: { ...(inherited?.kiro ?? {}), ...(emitted?.kiro ?? {}) } }
|
|
1811
|
+
: {}),
|
|
1812
|
+
...(cursorConversationId
|
|
1813
|
+
? {
|
|
1814
|
+
cursor: {
|
|
1815
|
+
...(inherited?.cursor ?? {}),
|
|
1816
|
+
...(emitted?.cursor ?? {}),
|
|
1817
|
+
conversationId: cursorConversationId,
|
|
1818
|
+
},
|
|
1819
|
+
}
|
|
1820
|
+
: {}),
|
|
1821
|
+
};
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
// Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
|
|
1825
|
+
// one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
|
|
1826
|
+
// natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
|
|
1827
|
+
// sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
|
|
1828
|
+
// A Responses-shaped wire does not imply support for Codex's private
|
|
1829
|
+
// `compaction_trigger` item — only the canonical ChatGPT backend speaks that
|
|
1830
|
+
// contract. An API-key gateway would receive the trigger, answer with an ordinary
|
|
1831
|
+
// message, and leave Codex fataling on a missing compaction item (#422).
|
|
1832
|
+
const routedCompaction = parsed._compactionRequest === true
|
|
1833
|
+
&& !isCanonicalOpenAiForwardProvider(route.provider);
|
|
1834
|
+
if (routedCompaction) {
|
|
1835
|
+
delete parsed.context.tools;
|
|
1836
|
+
delete parsed._webSearch;
|
|
1837
|
+
delete parsed.options.toolChoice;
|
|
1838
|
+
delete parsed.options.parallelToolCalls;
|
|
1839
|
+
// The compaction turn is a plain prose summary; a surviving structured-output format
|
|
1840
|
+
// would force schema-constrained JSON into the synthetic compaction item. The flag and
|
|
1841
|
+
// the raw `text` controls go too: Kiro's capability guard reads both and would reject
|
|
1842
|
+
// the turn outright, and the key-mode openai-responses adapter builds from _rawBody.
|
|
1843
|
+
delete parsed.options.textFormat;
|
|
1844
|
+
delete parsed._structuredOutput;
|
|
1845
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1846
|
+
delete (parsed._rawBody as Record<string, unknown>).text;
|
|
1847
|
+
}
|
|
1848
|
+
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
|
|
1852
|
+
let hostAdmissionLease = pendingHostAdmissionLease;
|
|
1853
|
+
pendingHostAdmissionLease = null;
|
|
1854
|
+
try {
|
|
1855
|
+
const imageGenCallAliases = route.provider.authMode === "forward"
|
|
1856
|
+
? new Map<string, { namespace: string; name: string }>()
|
|
1857
|
+
: imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
|
|
1858
|
+
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
1859
|
+
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
1860
|
+
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
1861
|
+
// way a chained turn keeps its earlier context is the local replay expansion. Record
|
|
1862
|
+
// completed passthrough responses (force bypasses Codex's blanket store:false) so the next
|
|
1863
|
+
// turn's expansion hits. Never record a body whose own previous_response_id failed to
|
|
1864
|
+
// expand: its input is a delta, and storing it would replay a truncated conversation.
|
|
1865
|
+
// Compaction turns are excluded: _rawBody still carries the full pre-compaction history and
|
|
1866
|
+
// recording it would let a later expansion rehydrate the chain Codex just replaced.
|
|
1867
|
+
const passthroughRecordEligible = parsed._compactionRequest !== true
|
|
1868
|
+
&& (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
|
|
1869
|
+
const rememberPassthroughResponse = passthroughRecordEligible
|
|
1870
|
+
? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
|
|
1871
|
+
rememberResponseState(parsed._rawBody, response, undefined, { force: true })
|
|
1872
|
+
: undefined;
|
|
1873
|
+
if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
1874
|
+
console.warn(
|
|
1875
|
+
`[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
|
|
1876
|
+
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
|
|
1880
|
+
try {
|
|
1881
|
+
request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
1884
|
+
throw error;
|
|
1885
|
+
}
|
|
1886
|
+
recordAdapterReasoning(logCtx, request);
|
|
1887
|
+
const actualHostKey = upstreamHostHealthKey(
|
|
1888
|
+
route.providerName,
|
|
1889
|
+
safeOriginLabel(request.url),
|
|
1890
|
+
);
|
|
1891
|
+
const hostKey = route.provider.authMode === "forward"
|
|
1892
|
+
? actualHostKey
|
|
1893
|
+
: null;
|
|
1894
|
+
const hostCircuitEnabled = hostKey !== null
|
|
1895
|
+
&& normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0;
|
|
1896
|
+
if (hostKey !== null && !hostCircuitEnabled) {
|
|
1897
|
+
disableUpstreamHostCircuitForKey(actualHostKey);
|
|
1898
|
+
}
|
|
1899
|
+
if (hostAdmissionLease && hostAdmissionLease.key !== hostKey) {
|
|
1900
|
+
return formatErrorResponse(502, "upstream_error", "Provider host changed after circuit admission");
|
|
1901
|
+
}
|
|
1902
|
+
if (options.abortSignal?.aborted) {
|
|
1903
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
1904
|
+
return clientCancelledResponse();
|
|
1905
|
+
}
|
|
1906
|
+
if (!hostAdmissionLease && hostCircuitEnabled) {
|
|
1907
|
+
const admission = acquireUpstreamHostAdmission(
|
|
1908
|
+
hostKey!,
|
|
1909
|
+
config.upstreamHostCircuitThreshold,
|
|
1910
|
+
);
|
|
1911
|
+
if (admission.kind === "blocked") {
|
|
1912
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
1913
|
+
return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds);
|
|
1914
|
+
}
|
|
1915
|
+
hostAdmissionLease = admission.lease;
|
|
1916
|
+
}
|
|
1917
|
+
const settleObservedHostResponse = (): void => {
|
|
1918
|
+
if (hostCircuitEnabled) {
|
|
1919
|
+
resetUpstreamHostHealth(actualHostKey, hostAdmissionLease);
|
|
1920
|
+
} else {
|
|
1921
|
+
resetUpstreamHostHealth(actualHostKey);
|
|
1922
|
+
}
|
|
1923
|
+
hostAdmissionLease = null;
|
|
1924
|
+
};
|
|
1925
|
+
const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
1926
|
+
? request.usageLog.inputTokens
|
|
1927
|
+
: undefined;
|
|
1928
|
+
if (passthroughEstimate !== undefined) {
|
|
1929
|
+
logCtx.usageLogInputTokens = passthroughEstimate;
|
|
1930
|
+
}
|
|
1931
|
+
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
1932
|
+
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
1933
|
+
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
1934
|
+
const upstream = new AbortController();
|
|
1935
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
1936
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
1937
|
+
let upstreamResponse: Response;
|
|
1938
|
+
const transportFailureResponse = (err: unknown): Response => {
|
|
1939
|
+
upstream.abort();
|
|
1940
|
+
if (options.abortSignal?.aborted) {
|
|
1941
|
+
releaseUpstreamHostAdmission(hostAdmissionLease);
|
|
1942
|
+
hostAdmissionLease = null;
|
|
1943
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
1944
|
+
return clientCancelledResponse();
|
|
1945
|
+
}
|
|
1946
|
+
const outcome = classifyTransportFailureKind(err);
|
|
1947
|
+
// Host-level evidence stands regardless of pool membership: a direct
|
|
1948
|
+
// forward send has no pool accounting, but the reachability failure is
|
|
1949
|
+
// still host-wide, not account evidence (#914 review).
|
|
1950
|
+
if (outcome === "connect_neutral") {
|
|
1951
|
+
if (hostCircuitEnabled) {
|
|
1952
|
+
recordUpstreamHostFailure(actualHostKey, {
|
|
1953
|
+
code: transportErrorCode(err),
|
|
1954
|
+
threshold: config.upstreamHostCircuitThreshold,
|
|
1955
|
+
lease: hostAdmissionLease,
|
|
1956
|
+
});
|
|
1957
|
+
} else {
|
|
1958
|
+
recordUpstreamHostFailure(actualHostKey, { code: transportErrorCode(err) });
|
|
1959
|
+
}
|
|
1960
|
+
hostAdmissionLease = null;
|
|
1961
|
+
} else {
|
|
1962
|
+
releaseUpstreamHostAdmission(hostAdmissionLease);
|
|
1963
|
+
hostAdmissionLease = null;
|
|
1964
|
+
}
|
|
1965
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1966
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1967
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1968
|
+
fixedAccount: authCtx.fixedAccount,
|
|
1969
|
+
modelId: route.modelId,
|
|
1970
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1971
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1972
|
+
writerGeneration: authCtx.writerGeneration,
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
const msg = outcome === "timeout"
|
|
1976
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
1977
|
+
: describeUpstreamConnectFailure(err, connectMs);
|
|
1978
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
1979
|
+
};
|
|
1980
|
+
try {
|
|
1981
|
+
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
1982
|
+
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
1983
|
+
// Body is a replayable string; nothing has streamed to the client yet.
|
|
1984
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
1985
|
+
recovery => {
|
|
1986
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
|
|
1987
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
1988
|
+
method: request.method,
|
|
1989
|
+
headers: request.headers,
|
|
1990
|
+
body: request.body,
|
|
1991
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider),
|
|
1992
|
+
route.provider.authMode === "forward")
|
|
1993
|
+
// Every real attempt response — including an intermediate 5xx the
|
|
1994
|
+
// retry wrapper replaces — proves the host was reached (#914 review).
|
|
1995
|
+
.then(res => {
|
|
1996
|
+
settleObservedHostResponse();
|
|
1997
|
+
return res;
|
|
1998
|
+
});
|
|
1999
|
+
},
|
|
2000
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
2001
|
+
);
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
return transportFailureResponse(err);
|
|
2004
|
+
} finally {
|
|
2005
|
+
request.releaseBodyObservation?.();
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the
|
|
2009
|
+
// passthrough wire. This branch returns before the recovery loop below, so Responses-shaped
|
|
2010
|
+
// key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429
|
|
2011
|
+
// immediately with no same-key replay. Pre-stream only — nothing has been relayed yet, so
|
|
2012
|
+
// the replay is lossless (same invariant as the recovery loop). Forward/OAuth providers
|
|
2013
|
+
// keep their pool logic below (rateLimitRetryPolicyFor returns null for them).
|
|
2014
|
+
const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
|
|
2015
|
+
let rateLimitRetries = 0;
|
|
2016
|
+
while (
|
|
2017
|
+
upstreamResponse.status === 429
|
|
2018
|
+
&& rateLimitPolicy !== null
|
|
2019
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
2020
|
+
) {
|
|
2021
|
+
rateLimitRetries += 1;
|
|
2022
|
+
// Release unread body + deliberate wait via the shared same-target helper.
|
|
2023
|
+
const retryAfterHeader = upstreamResponse.headers.get("retry-after");
|
|
2024
|
+
try {
|
|
2025
|
+
for await (const _ of prepareSameTarget429Wait({
|
|
2026
|
+
body: upstreamResponse.body,
|
|
2027
|
+
signal: options.abortSignal,
|
|
2028
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
2029
|
+
})) {
|
|
2030
|
+
// pre-stream: no stall watchdog to feed
|
|
2031
|
+
}
|
|
2032
|
+
} catch {
|
|
2033
|
+
upstream.abort();
|
|
2034
|
+
return clientCancelledResponse();
|
|
2035
|
+
}
|
|
2036
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
2037
|
+
// replay so the wire never starts work for a request the client already abandoned.
|
|
2038
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
2039
|
+
upstream.abort();
|
|
2040
|
+
return clientCancelledResponse();
|
|
2041
|
+
}
|
|
2042
|
+
try {
|
|
2043
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
2044
|
+
recovery => {
|
|
2045
|
+
// The first send of every replay is itself a rate-limit retry; inner transient-5xx
|
|
2046
|
+
// recoveries keep their own label (recovery is provided for those).
|
|
2047
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429");
|
|
2048
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
2049
|
+
method: request.method,
|
|
2050
|
+
headers: request.headers,
|
|
2051
|
+
body: request.body,
|
|
2052
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider),
|
|
2053
|
+
route.provider.authMode === "forward")
|
|
2054
|
+
.then(res => {
|
|
2055
|
+
settleObservedHostResponse();
|
|
2056
|
+
return res;
|
|
2057
|
+
});
|
|
2058
|
+
},
|
|
2059
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
2060
|
+
);
|
|
2061
|
+
} catch (err) {
|
|
2062
|
+
return transportFailureResponse(err);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider) && !authCtx.fixedAccount) {
|
|
2067
|
+
let poolRetryOutcome: number | undefined;
|
|
2068
|
+
if (await shouldRetryCodexPoolAccountModel400(
|
|
2069
|
+
upstreamResponse,
|
|
2070
|
+
route.modelId,
|
|
2071
|
+
options.abortSignal,
|
|
2072
|
+
)) {
|
|
2073
|
+
poolRetryOutcome = 400;
|
|
2074
|
+
} else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
2075
|
+
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
2076
|
+
poolRetryOutcome = upstreamResponse.status;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
if (poolRetryOutcome !== undefined) {
|
|
2080
|
+
const retry = await retryCodexPoolOnAlternateAccount({
|
|
2081
|
+
req,
|
|
2082
|
+
config,
|
|
2083
|
+
route,
|
|
2084
|
+
parsed,
|
|
2085
|
+
logCtx,
|
|
2086
|
+
options,
|
|
2087
|
+
firstAuthCtx: authCtx,
|
|
2088
|
+
firstResponse: upstreamResponse,
|
|
2089
|
+
outcomeStatus: poolRetryOutcome,
|
|
2090
|
+
upstream,
|
|
2091
|
+
connectMs,
|
|
2092
|
+
passthroughEstimate,
|
|
2093
|
+
stream: parsed.stream,
|
|
2094
|
+
});
|
|
2095
|
+
if (retry.kind === "transport") {
|
|
2096
|
+
authCtx = retry.authCtx;
|
|
2097
|
+
return transportFailureResponse(retry.error);
|
|
2098
|
+
}
|
|
2099
|
+
if (retry.kind === "retried") {
|
|
2100
|
+
authCtx = retry.authCtx;
|
|
2101
|
+
request = retry.request;
|
|
2102
|
+
upstreamResponse = retry.upstreamResponse;
|
|
2103
|
+
selectedForwardHeaders = retry.selectedForwardHeaders;
|
|
2104
|
+
// Keep subagent quota-failure health keyed to the account that actually served.
|
|
2105
|
+
subagentFallbackAccountId = retry.authCtx.accountId;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
2110
|
+
const resolvedModel = headers.get("openai-model")?.trim();
|
|
2111
|
+
if (resolvedModel) logCtx.resolvedModel = resolvedModel;
|
|
2112
|
+
if (isUsageDebugEnabled()) {
|
|
2113
|
+
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
2114
|
+
if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
|
|
2115
|
+
}
|
|
2116
|
+
// The chatgpt backend may omit Content-Type on SSE responses. Fall back to
|
|
2117
|
+
// treating a successful body as SSE when the caller requested streaming.
|
|
2118
|
+
const passthroughCt = headers.get("content-type")?.toLowerCase();
|
|
2119
|
+
const isEventStream = passthroughCt?.includes("text/event-stream")
|
|
2120
|
+
|| (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
|
|
2121
|
+
const terminalRecorder = codexForwardTerminalOutcomeRecorder(
|
|
2122
|
+
config,
|
|
2123
|
+
authCtx,
|
|
2124
|
+
route.provider,
|
|
2125
|
+
route.modelId,
|
|
2126
|
+
logCtx,
|
|
2127
|
+
req.headers.get("x-codex-parent-thread-id"),
|
|
2128
|
+
);
|
|
2129
|
+
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
2130
|
+
// Capture quota from upstream response for multi-account tracking
|
|
2131
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
2132
|
+
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
2133
|
+
// Prefer primary when present, fall back to secondary for compatibility.
|
|
2134
|
+
const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
|
|
2135
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
2136
|
+
applyAccountQuotaFromUpstreamHeaders(
|
|
2137
|
+
authCtx.accountId,
|
|
2138
|
+
upstreamResponse.headers,
|
|
2139
|
+
authCtx.writerGeneration,
|
|
2140
|
+
);
|
|
2141
|
+
if (terminalBodyWillRecord) {
|
|
2142
|
+
options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
|
|
2143
|
+
terminalRecorder(status, httpStatusOverride);
|
|
2144
|
+
if (status === "failed") {
|
|
2145
|
+
const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402
|
|
2146
|
+
|| logCtx.terminalHttpStatus === 429
|
|
2147
|
+
|| logCtx.terminalHttpStatus === 402
|
|
2148
|
+
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
2149
|
+
: undefined;
|
|
2150
|
+
if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) {
|
|
2151
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
2152
|
+
req.headers,
|
|
2153
|
+
subagentQuotaFailureModel,
|
|
2154
|
+
quotaFailureMessage,
|
|
2155
|
+
config,
|
|
2156
|
+
subagentFallbackAccountId,
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
options.onNativePassthroughTerminal?.(status);
|
|
2161
|
+
});
|
|
2162
|
+
} else if (!shouldDeferCodexResetDerivedCooldown(
|
|
2163
|
+
upstreamResponse,
|
|
2164
|
+
options.deferCodexResetDerivedCooldown,
|
|
2165
|
+
)) {
|
|
2166
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
2167
|
+
...quotaMeta,
|
|
2168
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
2169
|
+
fixedAccount: authCtx.fixedAccount,
|
|
2170
|
+
modelId: route.modelId,
|
|
2171
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
2172
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
2173
|
+
writerGeneration: authCtx.writerGeneration,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// Non-2xx passthrough failures must never reach Codex as an empty body —
|
|
2179
|
+
// Codex renders that as the opaque "Unknown error" (#452). Combo attempts
|
|
2180
|
+
// keep their typed failure envelope. Non-empty bodies are relayed verbatim
|
|
2181
|
+
// (headers included) so pool-retry Activation B/D and client diagnostics stay intact.
|
|
2182
|
+
// Manual-redirect policy (#914): a 3xx is relayed as-is (Location preserved
|
|
2183
|
+
// through sanitizePassthroughHeaders) so a redirect to a dead host can never
|
|
2184
|
+
// masquerade as a pre-connection failure after the credential was seen.
|
|
2185
|
+
// The numeric outcome above already classified it neutral — no streak.
|
|
2186
|
+
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
|
2187
|
+
return new Response(upstreamResponse.body, {
|
|
2188
|
+
status: upstreamResponse.status,
|
|
2189
|
+
statusText: upstreamResponse.statusText,
|
|
2190
|
+
headers: sanitizePassthroughHeaders(upstreamResponse.headers),
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
if (!upstreamResponse.ok) {
|
|
2194
|
+
if (options.comboAttempt) {
|
|
2195
|
+
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
|
|
2196
|
+
options.onConsumedComboFailure?.(failure);
|
|
2197
|
+
return failure.response;
|
|
2198
|
+
}
|
|
2199
|
+
const errorText = await upstreamResponse.text().catch(() => "");
|
|
2200
|
+
return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
|
|
2201
|
+
statusText: upstreamResponse.statusText,
|
|
2202
|
+
headers,
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
|
|
2207
|
+
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
|
|
2208
|
+
// native relay, never enters JS Sink.write); branch[1] is consumed in the
|
|
2209
|
+
// background for terminal-outcome/quota inspection only.
|
|
2210
|
+
// #314 alternative shape: win32 no-rewrite traffic follows the runtime/config
|
|
2211
|
+
// gate; darwin no-rewrite traffic joins it only for explicit
|
|
2212
|
+
// `streamMode: "eager-relay"` opt-in. Darwin `auto` always stays tee. The
|
|
2213
|
+
// eager shape skips tee and uses one bounded reader with inline inspection
|
|
2214
|
+
// (src/server/relay-eager.ts; policy:
|
|
2215
|
+
// devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md).
|
|
2216
|
+
// The bundled known-bad runtime remains on tee by default on both platforms.
|
|
2217
|
+
if (isEventStream && upstreamResponse.body) {
|
|
2218
|
+
const repairConfig = route.provider.responsesItemIdRepair;
|
|
2219
|
+
const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair);
|
|
2220
|
+
const githubCopilotRepairEnabled = route.providerName === "github-copilot";
|
|
2221
|
+
const responseModelRewrite = parsed._responseModelId !== undefined
|
|
2222
|
+
&& parsed._responseModelId !== parsed.modelId
|
|
2223
|
+
? createResponsesModelPayloadRewrite(parsed._responseModelId)
|
|
2224
|
+
: undefined;
|
|
2225
|
+
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
|
|
2226
|
+
const payloadRewrites = [
|
|
2227
|
+
createImageGenCallRestoreRewrite(imageGenCallAliases),
|
|
2228
|
+
hasResponsesItemIdRepair(repairConfig)
|
|
2229
|
+
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
|
|
2230
|
+
: undefined,
|
|
2231
|
+
responseModelRewrite,
|
|
2232
|
+
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
2233
|
+
// #893: sparse-snapshot gateways get field backfills AND lifecycle event
|
|
2234
|
+
// injection at the block level, after payload rewrites. Defaults come
|
|
2235
|
+
// from the finalized OUTBOUND body — the normalized internal tool shapes
|
|
2236
|
+
// are not the Responses wire shapes the snapshot must mirror.
|
|
2237
|
+
const snapshotDefaultsRequest = (() => {
|
|
2238
|
+
try {
|
|
2239
|
+
return JSON.parse(request.body) as unknown;
|
|
2240
|
+
} catch {
|
|
2241
|
+
return undefined;
|
|
2242
|
+
}
|
|
2243
|
+
})();
|
|
2244
|
+
const blockRewrites = [
|
|
2245
|
+
payloadRewrites.length > 0
|
|
2246
|
+
? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites))
|
|
2247
|
+
: undefined,
|
|
2248
|
+
githubCopilotRepairEnabled
|
|
2249
|
+
? createGithubCopilotResponsesBlockRewrite(translatorBudget)
|
|
2250
|
+
: undefined,
|
|
2251
|
+
snapshotRepairEnabled
|
|
2252
|
+
? createResponsesSnapshotBlockRewrite(snapshotDefaultsRequest, translatorBudget)
|
|
2253
|
+
: undefined,
|
|
2254
|
+
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
2255
|
+
const clientBlockRewrite = blockRewrites.length > 0
|
|
2256
|
+
? composeSseBlockRewrites(...blockRewrites)
|
|
2257
|
+
: undefined;
|
|
2258
|
+
const needsClientRewrite = clientBlockRewrite !== undefined;
|
|
2259
|
+
// #864: win32 rewrite traffic must never enter the tee()+JS-pull chain
|
|
2260
|
+
// (Bun#32111 JS-sink segfault — text frames pass, the terminal block is
|
|
2261
|
+
// lost). The eager single reader applies the same rewrites inline.
|
|
2262
|
+
const win32EagerRewrite = isWin32EagerRewrite(process.platform, needsClientRewrite);
|
|
2263
|
+
const eagerPath = selectEagerPath(
|
|
2264
|
+
process.platform,
|
|
2265
|
+
needsClientRewrite,
|
|
2266
|
+
config.streamMode ?? "auto",
|
|
2267
|
+
);
|
|
2268
|
+
const inlineEagerRewrite = needsClientRewrite
|
|
2269
|
+
&& (win32EagerRewrite || eagerPath?.useEagerRelay === true);
|
|
2270
|
+
if (eagerPath?.useEagerRelay || win32EagerRewrite) {
|
|
2271
|
+
const turnAc = new AbortController();
|
|
2272
|
+
linkAbortSignal(upstream, turnAc.signal);
|
|
2273
|
+
registerTurn(turnAc, options.turnAdmissionLease);
|
|
2274
|
+
const reportNativeTerminal = recordTerminalOutcomes
|
|
2275
|
+
? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
|
|
2276
|
+
terminalRecorder?.(status, httpStatusOverride);
|
|
2277
|
+
if (status === "failed") {
|
|
2278
|
+
const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402
|
|
2279
|
+
|| logCtx.terminalHttpStatus === 429
|
|
2280
|
+
|| logCtx.terminalHttpStatus === 402
|
|
2281
|
+
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
2282
|
+
: undefined;
|
|
2283
|
+
if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) {
|
|
2284
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
2285
|
+
req.headers,
|
|
2286
|
+
subagentQuotaFailureModel,
|
|
2287
|
+
quotaFailureMessage,
|
|
2288
|
+
config,
|
|
2289
|
+
subagentFallbackAccountId,
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
options.onNativePassthroughTerminal?.(status);
|
|
2294
|
+
}
|
|
2295
|
+
: undefined;
|
|
2296
|
+
const inspector = createSseInspector({
|
|
2297
|
+
onTerminal: reportNativeTerminal,
|
|
2298
|
+
logCtx,
|
|
2299
|
+
onCompletedResponse: rememberPassthroughResponse,
|
|
2300
|
+
onFirstOutput: options.onFirstOutput,
|
|
2301
|
+
pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled,
|
|
2302
|
+
});
|
|
2303
|
+
const eagerBody = relaySseEagerBounded(upstreamResponse.body, turnAc, {
|
|
2304
|
+
inspectChunk: chunk => inspector.feed(chunk),
|
|
2305
|
+
finishInspection: () => inspector.finish(),
|
|
2306
|
+
disposeInspection: () => inspector.dispose(),
|
|
2307
|
+
// Stream lifetime follows the protocol terminal even when this request
|
|
2308
|
+
// has no outcome callback configured (reported() would stay false).
|
|
2309
|
+
sawTerminal: () => inspector.terminalSeen(),
|
|
2310
|
+
...(clientBlockRewrite
|
|
2311
|
+
? { rewriteBlocks: clientBlockRewrite }
|
|
2312
|
+
: {}),
|
|
2313
|
+
onSynthetic: kind => {
|
|
2314
|
+
if (!reportNativeTerminal) return;
|
|
2315
|
+
if (kind === "incomplete") {
|
|
2316
|
+
logCtx.terminalSource = "synthetic";
|
|
2317
|
+
reportNativeTerminal("incomplete");
|
|
2318
|
+
} else {
|
|
2319
|
+
logCtx.transportPhase = "mid_stream";
|
|
2320
|
+
logCtx.terminalSource = "synthetic";
|
|
2321
|
+
reportNativeTerminal("failed", 502);
|
|
2322
|
+
}
|
|
2323
|
+
},
|
|
2324
|
+
onClientCancel: () => options.onNativePassthroughCancel?.(),
|
|
2325
|
+
onDone: () => unregisterTurn(turnAc),
|
|
2326
|
+
}, inlineEagerRewrite ? { rewriteBudget: translatorBudget } : undefined);
|
|
2327
|
+
// When selected, this relay closes response.completed even if upstream
|
|
2328
|
+
// keeps the connection alive. Windows forced-rewrite traffic and Darwin
|
|
2329
|
+
// explicit eager traffic apply client rewrites inline rather than via
|
|
2330
|
+
// the tee()+JS-pull chain.
|
|
2331
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
2332
|
+
return markEagerRelaySseResponse(
|
|
2333
|
+
markNativePassthroughSseResponse(new Response(eagerBody, {
|
|
2334
|
+
status: upstreamResponse.status,
|
|
2335
|
+
headers,
|
|
2336
|
+
})),
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
2340
|
+
const turnAc = new AbortController();
|
|
2341
|
+
const clientGone = new AbortController();
|
|
2342
|
+
linkAbortSignal(upstream, turnAc.signal);
|
|
2343
|
+
registerTurn(turnAc, options.turnAdmissionLease);
|
|
2344
|
+
const inspectionConsumerOptions = {
|
|
2345
|
+
clientGoneSignal: clientGone.signal,
|
|
2346
|
+
drainBounds: { ms: 15_000, bytes: 32 * 1024 * 1024 },
|
|
2347
|
+
upstream,
|
|
2348
|
+
pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled,
|
|
2349
|
+
};
|
|
2350
|
+
if (recordTerminalOutcomes) {
|
|
2351
|
+
// A real terminal was parsed from the (teed) inspection stream — record it as the outcome
|
|
2352
|
+
// even if the client has already disconnected: the turn genuinely reached that terminal, so
|
|
2353
|
+
// it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
|
|
2354
|
+
// client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
|
|
2355
|
+
const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
|
|
2356
|
+
terminalRecorder?.(status, httpStatusOverride);
|
|
2357
|
+
if (status === "failed") {
|
|
2358
|
+
const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402
|
|
2359
|
+
|| logCtx.terminalHttpStatus === 429
|
|
2360
|
+
|| logCtx.terminalHttpStatus === 402
|
|
2361
|
+
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
2362
|
+
: undefined;
|
|
2363
|
+
if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) {
|
|
2364
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
2365
|
+
req.headers,
|
|
2366
|
+
subagentQuotaFailureModel,
|
|
2367
|
+
quotaFailureMessage,
|
|
2368
|
+
config,
|
|
2369
|
+
subagentFallbackAccountId,
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
options.onNativePassthroughTerminal?.(status);
|
|
2374
|
+
};
|
|
2375
|
+
consumeForInspection(
|
|
2376
|
+
inspectBody,
|
|
2377
|
+
reportNativeTerminal,
|
|
2378
|
+
turnAc.signal,
|
|
2379
|
+
() => unregisterTurn(turnAc),
|
|
2380
|
+
logCtx,
|
|
2381
|
+
() => options.onNativePassthroughCancel?.(),
|
|
2382
|
+
rememberPassthroughResponse,
|
|
2383
|
+
options.onFirstOutput,
|
|
2384
|
+
inspectionConsumerOptions,
|
|
2385
|
+
);
|
|
2386
|
+
} else {
|
|
2387
|
+
consumeForResponseLogMetadata(
|
|
2388
|
+
inspectBody,
|
|
2389
|
+
logCtx,
|
|
2390
|
+
turnAc.signal,
|
|
2391
|
+
() => unregisterTurn(turnAc),
|
|
2392
|
+
rememberPassthroughResponse,
|
|
2393
|
+
options.onFirstOutput,
|
|
2394
|
+
inspectionConsumerOptions,
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
2398
|
+
// Windows was handled by the eager terminal-aware branch above. Remaining
|
|
2399
|
+
// tee traffic can use the JS relay to close on a protocol terminal and to
|
|
2400
|
+
// convert a mid-stream reset into a clean response.failed event.
|
|
2401
|
+
const rewrittenBody = clientBlockRewrite !== undefined
|
|
2402
|
+
? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget)
|
|
2403
|
+
: nativeBody;
|
|
2404
|
+
const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason));
|
|
2405
|
+
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
2406
|
+
status: upstreamResponse.status,
|
|
2407
|
+
headers,
|
|
2408
|
+
}));
|
|
2409
|
+
}
|
|
2410
|
+
if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
2411
|
+
// Bounded whole-body read: a non-streaming upstream JSON body is fully materialized
|
|
2412
|
+
// here (and again by the request-log finalizer and the WebSocket bridge's reframing),
|
|
2413
|
+
// so an unbounded .text() would let a hostile or stuck upstream grow proxy memory
|
|
2414
|
+
// without limit. This path is no longer rare — WebSocket turns for models whose
|
|
2415
|
+
// streaming terminal event is unreliable are deliberately answered with bounded JSON.
|
|
2416
|
+
// Oversize and stall deadlines both fail closed; a partial body is never parsed.
|
|
2417
|
+
const bounded = await readBoundedResponseBody(upstreamResponse, UPSTREAM_JSON_BODY_READ_OPTIONS);
|
|
2418
|
+
if (bounded.oversized) {
|
|
2419
|
+
return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit");
|
|
2420
|
+
}
|
|
2421
|
+
if (bounded.truncated) {
|
|
2422
|
+
return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing");
|
|
2423
|
+
}
|
|
2424
|
+
const text = bounded.text;
|
|
2425
|
+
inspectResponseLogJson(logCtx, text);
|
|
2426
|
+
if (rememberPassthroughResponse) {
|
|
2427
|
+
try {
|
|
2428
|
+
rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
|
|
2429
|
+
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
2430
|
+
}
|
|
2431
|
+
const clientJson = (() => {
|
|
2432
|
+
const restored = restoreImageGenCallsInJson(text, imageGenCallAliases);
|
|
2433
|
+
const repaired = (() => {
|
|
2434
|
+
if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored;
|
|
2435
|
+
let outbound: unknown;
|
|
2436
|
+
try {
|
|
2437
|
+
outbound = JSON.parse(request.body);
|
|
2438
|
+
} catch {
|
|
2439
|
+
outbound = undefined;
|
|
2440
|
+
}
|
|
2441
|
+
return repairResponsesSnapshotJson(restored, outbound);
|
|
2442
|
+
})();
|
|
2443
|
+
return parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
|
|
2444
|
+
? rewriteResponsesModelJson(repaired, parsed._responseModelId)
|
|
2445
|
+
: repaired;
|
|
2446
|
+
})();
|
|
2447
|
+
// #875: the transport-neutral reliability policy forced a bounded JSON
|
|
2448
|
+
// upstream for a client that asked for SSE. Reframe the completed JSON
|
|
2449
|
+
// as the canonical terminal SSE sequence (created → output_item.done →
|
|
2450
|
+
// terminal → [DONE]) so Codex commits the turn instead of hanging on a
|
|
2451
|
+
// stream that never closes. Non-streaming clients keep the plain JSON.
|
|
2452
|
+
if (clientRequestedStream === true
|
|
2453
|
+
&& options.inboundTransport !== "websocket"
|
|
2454
|
+
&& providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false
|
|
2455
|
+
&& route.provider.adapter === "openai-responses") {
|
|
2456
|
+
try {
|
|
2457
|
+
let completed = JSON.parse(clientJson) as Record<string, unknown>;
|
|
2458
|
+
// The bounded-JSON answer bypasses the SSE relay, so it also bypasses
|
|
2459
|
+
// the SSE item-id rewrite. Apply the same client-facing normalization
|
|
2460
|
+
// here or this policy would silently disable id repair for the very
|
|
2461
|
+
// providers that need it (raw record already happened above).
|
|
2462
|
+
if (hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)) {
|
|
2463
|
+
completed = repairResponsesJsonItemIds(completed, route.provider.responsesItemIdRepair!, translatorBudget);
|
|
2464
|
+
}
|
|
2465
|
+
const sseHeaders = sanitizePassthroughHeaders(headers);
|
|
2466
|
+
sseHeaders.set("content-type", "text/event-stream");
|
|
2467
|
+
sseHeaders.set("cache-control", "no-store");
|
|
2468
|
+
return new Response(responsesJsonToSseBody(completed), {
|
|
2469
|
+
status: upstreamResponse.status,
|
|
2470
|
+
statusText: upstreamResponse.statusText,
|
|
2471
|
+
headers: sseHeaders,
|
|
2472
|
+
});
|
|
2473
|
+
} catch {
|
|
2474
|
+
// Non-JSON despite content-type: fall through to the plain relay.
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
// WS turns reframe this JSON into events in the bridge, which is the
|
|
2478
|
+
// other relay-free path — normalize ids so both bounded-JSON paths agree.
|
|
2479
|
+
const outboundJson = options.inboundTransport === "websocket"
|
|
2480
|
+
&& providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false
|
|
2481
|
+
&& hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)
|
|
2482
|
+
? (() => {
|
|
2483
|
+
try {
|
|
2484
|
+
return JSON.stringify(repairResponsesJsonItemIds(
|
|
2485
|
+
JSON.parse(clientJson) as Record<string, unknown>,
|
|
2486
|
+
route.provider.responsesItemIdRepair!,
|
|
2487
|
+
translatorBudget,
|
|
2488
|
+
));
|
|
2489
|
+
} catch {
|
|
2490
|
+
return clientJson;
|
|
2491
|
+
}
|
|
2492
|
+
})()
|
|
2493
|
+
: clientJson;
|
|
2494
|
+
return new Response(outboundJson, {
|
|
2495
|
+
status: upstreamResponse.status,
|
|
2496
|
+
statusText: upstreamResponse.statusText,
|
|
2497
|
+
headers,
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
const body = relayWithAbort(upstreamResponse.body, upstream);
|
|
2501
|
+
const turnAc = new AbortController();
|
|
2502
|
+
const tracked = body ? trackStreamLifetime(body, turnAc, undefined, options.turnAdmissionLease) : null;
|
|
2503
|
+
return new Response(tracked, {
|
|
2504
|
+
status: upstreamResponse.status,
|
|
2505
|
+
headers,
|
|
2506
|
+
});
|
|
2507
|
+
} finally {
|
|
2508
|
+
if (hostAdmissionLease) {
|
|
2509
|
+
releaseUpstreamHostAdmission(hostAdmissionLease);
|
|
2510
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
|
|
2516
|
+
// Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
|
|
2517
|
+
// leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
|
|
2518
|
+
// completion instead of the synthetic compaction item Codex expects (#424).
|
|
2519
|
+
//
|
|
2520
|
+
// Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
|
|
2521
|
+
// Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
|
|
2522
|
+
// - non-runTurn: web-search wins over image when both eligible (documented priority)
|
|
2523
|
+
// - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
|
|
2524
|
+
// can proceed for web-search-only turns
|
|
2525
|
+
const wsPlan = !routedCompaction
|
|
2526
|
+
? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
|
|
2527
|
+
: undefined;
|
|
2528
|
+
const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
|
|
2529
|
+
const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
|
|
2530
|
+
const canRunWebSearch = !!wsPlan && !adapter.runTurn;
|
|
2531
|
+
if ((imgPlan || vidPlan) && canRunWebSearch) {
|
|
2532
|
+
// Web search takes priority when both are active — the media bridge cannot run
|
|
2533
|
+
// alongside runWithWebSearch. Surface a runtime signal so the user knows their
|
|
2534
|
+
// configured video/image bridge was skipped for this turn, rather than silently
|
|
2535
|
+
// dropping a paid capability.
|
|
2536
|
+
if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
|
|
2537
|
+
if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
|
|
2538
|
+
}
|
|
2539
|
+
if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
|
|
2540
|
+
// The image bridge detects a hosted image_generation tool and requires streaming.
|
|
2541
|
+
// The video bridge activates from config and injects a tool — it also needs streaming
|
|
2542
|
+
// (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
|
|
2543
|
+
// the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
|
|
2544
|
+
if (!parsed.stream) {
|
|
2545
|
+
if (imgPlan) {
|
|
2546
|
+
return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
|
|
2547
|
+
}
|
|
2548
|
+
// Video-only: skip bridge for non-streaming requests
|
|
2549
|
+
} else {
|
|
2550
|
+
// Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
|
|
2551
|
+
const priorTools = parsed.context.tools ?? [];
|
|
2552
|
+
const bridgeTools = [...priorTools.filter(t => {
|
|
2553
|
+
if (t.imageGeneration) return false;
|
|
2554
|
+
if (t.videoGeneration) return false;
|
|
2555
|
+
if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
|
|
2556
|
+
if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
|
|
2557
|
+
// Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
|
|
2558
|
+
if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
|
|
2559
|
+
return true;
|
|
2560
|
+
})];
|
|
2561
|
+
const existingNames = new Set(bridgeTools.map(t => t.name));
|
|
2562
|
+
if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
|
|
2563
|
+
if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
|
|
2564
|
+
parsed.context.tools = bridgeTools;
|
|
2565
|
+
// Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
|
|
2566
|
+
// Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
|
|
2567
|
+
// image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
|
|
2568
|
+
const tc = parsed.options.toolChoice;
|
|
2569
|
+
if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
|
|
2570
|
+
const mapped = tc.allowedTools.map(name =>
|
|
2571
|
+
name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
|
|
2572
|
+
? IMAGE_GEN_TOOL_NAME
|
|
2573
|
+
: name,
|
|
2574
|
+
);
|
|
2575
|
+
parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
|
|
2576
|
+
} else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
|
|
2577
|
+
&& (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
|
|
2578
|
+
parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
|
|
2579
|
+
}
|
|
2580
|
+
const imgResponse = await runWithImageBridge({
|
|
2581
|
+
parsed, adapter,
|
|
2582
|
+
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
2583
|
+
...(imgPlan ? { plan: imgPlan } : {}),
|
|
2584
|
+
...(vidPlan ? { videoPlan: vidPlan } : {}),
|
|
2585
|
+
forwardHeaders: selectedForwardHeaders,
|
|
2586
|
+
onAttemptSend: (recovery?: AttemptRecoveryKind) =>
|
|
2587
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
|
|
2588
|
+
abortSignal: options.abortSignal,
|
|
2589
|
+
maxRounds: imgPlan && vidPlan
|
|
2590
|
+
? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
|
|
2591
|
+
: imgPlan
|
|
2592
|
+
? clampImageMaxRounds(config.images?.maxRounds)
|
|
2593
|
+
: clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
|
|
2594
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2595
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
2596
|
+
fetchImpl: providerFetch(route.provider),
|
|
2597
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
2598
|
+
...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
|
|
2599
|
+
onUsage: usage => {
|
|
2600
|
+
// Cursor may assign _cursorConversationId inside the image loop's first runTurn;
|
|
2601
|
+
// backfill so Logs can filter/total that opening request (parity with the normal
|
|
2602
|
+
// runTurn branch).
|
|
2603
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
2604
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
2605
|
+
}
|
|
2606
|
+
logCtx.usageFromBridge = true;
|
|
2607
|
+
if (usage) {
|
|
2608
|
+
logCtx.usage = usage;
|
|
2609
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2610
|
+
}
|
|
2611
|
+
},
|
|
2612
|
+
on429: retryAfter => {
|
|
2613
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2614
|
+
retryAfter,
|
|
2615
|
+
now: Date.now(),
|
|
2616
|
+
attemptedKey: route.provider.apiKey,
|
|
2617
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
2618
|
+
});
|
|
2619
|
+
if (!rotated) return null;
|
|
2620
|
+
route.provider = rotated;
|
|
2621
|
+
return resolveAdapter(
|
|
2622
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2623
|
+
config.cacheRetention,
|
|
2624
|
+
);
|
|
2625
|
+
},
|
|
2626
|
+
retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
|
|
2627
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2628
|
+
...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
|
|
2629
|
+
onCompletedResponse: (response, providerState) =>
|
|
2630
|
+
rememberResponseState(
|
|
2631
|
+
parsed._rawBody,
|
|
2632
|
+
response,
|
|
2633
|
+
continuationStateForResponse(providerState),
|
|
2634
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
2635
|
+
),
|
|
2636
|
+
});
|
|
2637
|
+
if (imgResponse.body) {
|
|
2638
|
+
const imgTurnAc = new AbortController();
|
|
2639
|
+
return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, undefined, options.turnAdmissionLease), {
|
|
2640
|
+
status: imgResponse.status,
|
|
2641
|
+
headers: imgResponse.headers,
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
return imgResponse;
|
|
2645
|
+
} // end else (streaming bridge)
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
2649
|
+
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
2650
|
+
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
2651
|
+
// Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
|
|
2652
|
+
// through web-search instead of being swallowed. runTurn adapters never enter this branch.
|
|
2653
|
+
if (canRunWebSearch && wsPlan) {
|
|
2654
|
+
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
2655
|
+
const wsResponse = await runWithWebSearch({
|
|
2656
|
+
parsed, adapter,
|
|
2657
|
+
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
2658
|
+
backend: wsPlan.backend,
|
|
2659
|
+
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
2660
|
+
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
2661
|
+
hostedTool: wsPlan.hostedTool,
|
|
2662
|
+
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
2663
|
+
settings: wsPlan.settings,
|
|
2664
|
+
maxSearches: wsPlan.maxSearches,
|
|
2665
|
+
forceEmptyResponseId: true,
|
|
2666
|
+
abortSignal: options.abortSignal,
|
|
2667
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2668
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
2669
|
+
onAttemptSend: (recovery?: AttemptRecoveryKind) =>
|
|
2670
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
|
|
2671
|
+
onUsage: usage => {
|
|
2672
|
+
logCtx.usageFromBridge = true;
|
|
2673
|
+
if (usage) {
|
|
2674
|
+
logCtx.usage = usage;
|
|
2675
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2676
|
+
}
|
|
2677
|
+
},
|
|
2678
|
+
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
2679
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2680
|
+
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
2681
|
+
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
2682
|
+
on429: retryAfter => {
|
|
2683
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2684
|
+
retryAfter,
|
|
2685
|
+
now: Date.now(),
|
|
2686
|
+
attemptedKey: route.provider.apiKey,
|
|
2687
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
2688
|
+
});
|
|
2689
|
+
if (!rotated) return null;
|
|
2690
|
+
route.provider = rotated;
|
|
2691
|
+
return resolveAdapter(
|
|
2692
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2693
|
+
config.cacheRetention,
|
|
2694
|
+
);
|
|
2695
|
+
},
|
|
2696
|
+
retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
|
|
2697
|
+
});
|
|
2698
|
+
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
2699
|
+
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
2700
|
+
if (wsResponse.body) {
|
|
2701
|
+
const wsTurnAc = new AbortController();
|
|
2702
|
+
return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, undefined, options.turnAdmissionLease), {
|
|
2703
|
+
status: wsResponse.status,
|
|
2704
|
+
headers: wsResponse.headers,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
return wsResponse;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
if (adapter.runTurn) {
|
|
2711
|
+
const runTurnAbort = new AbortController();
|
|
2712
|
+
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
2713
|
+
const queue = createAdapterEventQueue({
|
|
2714
|
+
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
2715
|
+
});
|
|
2716
|
+
const runTurn = async (): Promise<void> => {
|
|
2717
|
+
try {
|
|
2718
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
2719
|
+
await adapter.runTurn?.(
|
|
2720
|
+
parsed,
|
|
2721
|
+
{ headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget },
|
|
2722
|
+
queue.push,
|
|
2723
|
+
);
|
|
2724
|
+
} catch (err) {
|
|
2725
|
+
queue.push({
|
|
2726
|
+
type: "error",
|
|
2727
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2728
|
+
});
|
|
2729
|
+
} finally {
|
|
2730
|
+
// Cursor assigns a stable conversation id inside runTurn on the first headerless
|
|
2731
|
+
// turn; backfill so Logs can filter/total that opening request (#330 / #522).
|
|
2732
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
2733
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
2734
|
+
}
|
|
2735
|
+
queue.close();
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2739
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2740
|
+
if (parsed.stream) {
|
|
2741
|
+
void runTurn();
|
|
2742
|
+
let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
|
|
2743
|
+
if (options.comboAttempt) {
|
|
2744
|
+
const preflight = await preflightAdapterEvents(eventSource);
|
|
2745
|
+
if (preflight.error || preflight.empty) {
|
|
2746
|
+
runTurnAbort.abort();
|
|
2747
|
+
queue.close();
|
|
2748
|
+
const message = preflight.error?.message ?? "Adapter ended before producing a response";
|
|
2749
|
+
return formatErrorResponse(502, "upstream_error", redactSecretString(message));
|
|
2750
|
+
}
|
|
2751
|
+
eventSource = preflight.stream;
|
|
2752
|
+
}
|
|
2753
|
+
const sseStream = bridgeToResponsesSSE(
|
|
2754
|
+
eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
2755
|
+
() => {
|
|
2756
|
+
runTurnAbort.abort();
|
|
2757
|
+
queue.close();
|
|
2758
|
+
}, 2_000,
|
|
2759
|
+
{
|
|
2760
|
+
translatorBudget,
|
|
2761
|
+
replayCacheScope: parsed._clientThreadId ?? "global",
|
|
2762
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
2763
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
2764
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2765
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2766
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
2767
|
+
onUsage: usage => {
|
|
2768
|
+
// Raw adapter usage, pre wire-normalization: the bridged SSE now always carries
|
|
2769
|
+
// zero-default detail objects, so provenance must come from here (cache_detail_missing).
|
|
2770
|
+
logCtx.usageFromBridge = true;
|
|
2771
|
+
if (usage) {
|
|
2772
|
+
logCtx.usage = usage;
|
|
2773
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2774
|
+
}
|
|
2775
|
+
},
|
|
2776
|
+
...(routedCompaction ? {} : {
|
|
2777
|
+
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) =>
|
|
2778
|
+
rememberResponseState(
|
|
2779
|
+
parsed._rawBody,
|
|
2780
|
+
response,
|
|
2781
|
+
continuationStateForResponse(providerState),
|
|
2782
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
2783
|
+
),
|
|
2784
|
+
}),
|
|
2785
|
+
},
|
|
2786
|
+
);
|
|
2787
|
+
const bridgeTurnAc = new AbortController();
|
|
2788
|
+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, undefined, options.turnAdmissionLease);
|
|
2789
|
+
return new Response(trackedSse, {
|
|
2790
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
await runTurn();
|
|
2795
|
+
const events = await queue.collect();
|
|
2796
|
+
if (options.comboAttempt) {
|
|
2797
|
+
const firstMeaningful = events.find(event => event.type !== "heartbeat");
|
|
2798
|
+
if (!firstMeaningful || firstMeaningful.type === "error") {
|
|
2799
|
+
const message = firstMeaningful?.type === "error"
|
|
2800
|
+
? firstMeaningful.message
|
|
2801
|
+
: "Adapter ended before producing a response";
|
|
2802
|
+
return formatErrorResponse(502, "upstream_error", redactSecretString(message));
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
let providerState: OcxProviderContinuationState | undefined;
|
|
2806
|
+
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
|
|
2807
|
+
translatorBudget,
|
|
2808
|
+
replayCacheScope: parsed._clientThreadId ?? "global",
|
|
2809
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2810
|
+
toolNsMap,
|
|
2811
|
+
freeformToolNames,
|
|
2812
|
+
toolSearchToolNames,
|
|
2813
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
2814
|
+
onProviderState: state => { providerState = state; },
|
|
2815
|
+
onUsage: usage => {
|
|
2816
|
+
logCtx.usageFromBridge = true;
|
|
2817
|
+
if (usage) {
|
|
2818
|
+
logCtx.usage = usage;
|
|
2819
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2820
|
+
}
|
|
2821
|
+
},
|
|
2822
|
+
});
|
|
2823
|
+
if (!routedCompaction) {
|
|
2824
|
+
rememberResponseState(
|
|
2825
|
+
parsed._rawBody,
|
|
2826
|
+
json,
|
|
2827
|
+
continuationStateForResponse(providerState),
|
|
2828
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
2829
|
+
);
|
|
2830
|
+
}
|
|
2831
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
const upstream = new AbortController();
|
|
2835
|
+
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
2836
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
2837
|
+
// Bridge stall budget (seconds of silence before upstream_stall_timeout); the retry backoff
|
|
2838
|
+
// heartbeat interval is derived from it so the watchdog is always fed during deliberate waits.
|
|
2839
|
+
const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0
|
|
2840
|
+
? Math.floor(config.stallTimeoutSec * 1000)
|
|
2841
|
+
: 300_000;
|
|
2842
|
+
let activeAdapter = adapter;
|
|
2843
|
+
|
|
2844
|
+
// One immutable, body-safe outbound request per same-target sequence (URL, serialized body,
|
|
2845
|
+
// auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the
|
|
2846
|
+
// builder runs again only after a key/account/adapter rotation, an oauth refresh, or an
|
|
2847
|
+
// image-tier bias change (transportToken bump). `body` is always a serialized string, so
|
|
2848
|
+
// reuse is safe, and releaseBodyObservation is idempotent per build.
|
|
2849
|
+
let initialRequest: AdapterRequest | undefined;
|
|
2850
|
+
let inputTokenEstimate: number | undefined;
|
|
2851
|
+
try {
|
|
2852
|
+
initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
2853
|
+
recordAdapterReasoning(logCtx, initialRequest);
|
|
2854
|
+
inputTokenEstimate = typeof initialRequest.usageLog?.inputTokens === "number"
|
|
2855
|
+
? initialRequest.usageLog.inputTokens
|
|
2856
|
+
: undefined;
|
|
2857
|
+
if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate;
|
|
2858
|
+
} catch (err) {
|
|
2859
|
+
// A throwing buildRequest never returned a request; if a post-build step threw, release
|
|
2860
|
+
// the serialized-body observation (idempotent) so the translator budget is not leaked.
|
|
2861
|
+
// The build runs after linkAbortSignal, so a failure must also tear the link down and
|
|
2862
|
+
// abort the upstream controller instead of escaping handleResponses unmapped.
|
|
2863
|
+
initialRequest?.releaseBodyObservation?.();
|
|
2864
|
+
cleanupUpstreamAbort();
|
|
2865
|
+
upstream.abort();
|
|
2866
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
2867
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2868
|
+
return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
|
|
2869
|
+
}
|
|
2870
|
+
// The catch path above always returns, so the request is definitely assigned here.
|
|
2871
|
+
// Capture it in a const so the fetch callbacks read a narrowed, immutable value
|
|
2872
|
+
// (TypeScript drops narrowing for a `let` captured by a nested function).
|
|
2873
|
+
const builtInitialRequest = initialRequest;
|
|
2874
|
+
let sameTargetRequest: AdapterRequest | undefined = builtInitialRequest;
|
|
2875
|
+
let sameTargetParsed: OcxParsedRequest | undefined = parsed;
|
|
2876
|
+
let sameTargetToken = 0;
|
|
2877
|
+
let transportToken = 0;
|
|
2878
|
+
/**
|
|
2879
|
+
* Invalidate the same-target request cache. Every credential/adapter/parsed mutation MUST
|
|
2880
|
+
* go through here: the cache keys on `parsed` REFERENCE identity, so an in-place mutation
|
|
2881
|
+
* is invisible to it and a missed bump would replay a request built with a stale key.
|
|
2882
|
+
*/
|
|
2883
|
+
const invalidateSameTargetRequest = (): void => { transportToken += 1; };
|
|
2884
|
+
let upstreamResponse: Response;
|
|
2885
|
+
try {
|
|
2886
|
+
if (activeAdapter.fetchResponse) {
|
|
2887
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
|
|
2888
|
+
upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, {
|
|
2889
|
+
abortSignal: upstream.signal,
|
|
2890
|
+
timeoutMs: connectMs,
|
|
2891
|
+
stream: parsed.stream,
|
|
2892
|
+
});
|
|
2893
|
+
} else {
|
|
2894
|
+
upstreamResponse = await fetchWithTransientNonJsonRetry(
|
|
2895
|
+
recovery => {
|
|
2896
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
|
|
2897
|
+
return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
|
|
2898
|
+
method: builtInitialRequest.method,
|
|
2899
|
+
headers: builtInitialRequest.headers,
|
|
2900
|
+
body: builtInitialRequest.body,
|
|
2901
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
2902
|
+
},
|
|
2903
|
+
{
|
|
2904
|
+
abortSignal: upstream.signal,
|
|
2905
|
+
label: safeHostLabel(builtInitialRequest.url),
|
|
2906
|
+
statuses: providerPreStreamNonJsonRetryStatuses(route.providerName, route.provider),
|
|
2907
|
+
},
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
} catch (err) {
|
|
2911
|
+
cleanupUpstreamAbort();
|
|
2912
|
+
upstream.abort();
|
|
2913
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
2914
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2915
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
2916
|
+
} finally {
|
|
2917
|
+
builtInitialRequest.releaseBodyObservation?.();
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// Same-target 429 retry budget is per REQUEST: it lives OUTSIDE the recovery loop (so a 413/401
|
|
2921
|
+
// replay that comes back 429 cannot silently re-arm a fresh budget) and is SHARED with the
|
|
2922
|
+
// terminal-guard continuation below, so the main loop + one continuation can never exceed
|
|
2923
|
+
// `attempts` same-key replays in total (bounded per request).
|
|
2924
|
+
const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
|
|
2925
|
+
let rateLimitRetries = 0;
|
|
2926
|
+
// Shared with the terminal-guard continuation below: an image-tier reduction that let the
|
|
2927
|
+
// main request clear a 413 must not be forgotten on the very next continuation build.
|
|
2928
|
+
let imageTierBias = 0;
|
|
2929
|
+
if (!upstreamResponse.ok) {
|
|
2930
|
+
// Recovery loop: multi-key 429 failover + at most ONE anthropic 413 tightened retry
|
|
2931
|
+
// (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
|
|
2932
|
+
// both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
|
|
2933
|
+
// adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a
|
|
2934
|
+
// 413→429 rotation cannot silently undo the tightening.
|
|
2935
|
+
let imageRetryAttempted = false;
|
|
2936
|
+
let oauth401ReplayAttempted = false;
|
|
2937
|
+
/**
|
|
2938
|
+
* Rebuild the request from the current parsed input (and any image-tier bias) and refetch
|
|
2939
|
+
* it once, tagging the attempt with the given recovery kind. Rebuilds are deterministic
|
|
2940
|
+
* for the same parsed request, so same-target replays stay byte-identical.
|
|
2941
|
+
*/
|
|
2942
|
+
const rebuildAndRefetch = async (
|
|
2943
|
+
recovery: AttemptRecoveryKind,
|
|
2944
|
+
): Promise<Response | { failed: Response }> => {
|
|
2945
|
+
let retryRequest: AdapterRequest;
|
|
2946
|
+
if (sameTargetRequest !== undefined && sameTargetParsed === parsed && sameTargetToken === transportToken) {
|
|
2947
|
+
// Same target (key/adapter/parsed/tier unchanged): replay the exact cached request.
|
|
2948
|
+
retryRequest = sameTargetRequest;
|
|
2949
|
+
} else {
|
|
2950
|
+
try {
|
|
2951
|
+
retryRequest = await activeAdapter.buildRequest(parsed, {
|
|
2952
|
+
headers: selectedForwardHeaders,
|
|
2953
|
+
translatorBudget,
|
|
2954
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
2955
|
+
});
|
|
2956
|
+
recordAdapterReasoning(logCtx, retryRequest);
|
|
2957
|
+
} catch (err) {
|
|
2958
|
+
// A rotated/rebuilt adapter build failure is a request-shaping error, not an
|
|
2959
|
+
// upstream connect failure: tear the abort link down and map it as 400 (no 413
|
|
2960
|
+
// translator-budget mapping here — that stays with parseRequest/buildToolBridgeMaps).
|
|
2961
|
+
cleanupUpstreamAbort();
|
|
2962
|
+
upstream.abort();
|
|
2963
|
+
if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() };
|
|
2964
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2965
|
+
return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) };
|
|
2966
|
+
}
|
|
2967
|
+
sameTargetRequest = retryRequest;
|
|
2968
|
+
sameTargetParsed = parsed;
|
|
2969
|
+
sameTargetToken = transportToken;
|
|
2970
|
+
}
|
|
2971
|
+
const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number"
|
|
2972
|
+
? retryRequest.usageLog.inputTokens
|
|
2973
|
+
: undefined;
|
|
2974
|
+
if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
|
|
2975
|
+
logCtx.providerAdapter = activeAdapter.name;
|
|
2976
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2977
|
+
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
|
|
2978
|
+
try {
|
|
2979
|
+
try {
|
|
2980
|
+
return activeAdapter.fetchResponse
|
|
2981
|
+
? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
2982
|
+
: await fetchWithHeaderTimeout(retryRequest.url, {
|
|
2983
|
+
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
2984
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
2985
|
+
} finally {
|
|
2986
|
+
retryRequest.releaseBodyObservation?.();
|
|
2987
|
+
}
|
|
2988
|
+
} catch (err) {
|
|
2989
|
+
cleanupUpstreamAbort();
|
|
2990
|
+
upstream.abort();
|
|
2991
|
+
if (options.abortSignal?.aborted) {
|
|
2992
|
+
return { failed: clientCancelledResponse() };
|
|
2993
|
+
}
|
|
2994
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2995
|
+
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
2996
|
+
}
|
|
2997
|
+
};
|
|
2998
|
+
recovery: for (;;) {
|
|
2999
|
+
if (
|
|
3000
|
+
upstreamResponse.status === 401
|
|
3001
|
+
&& isOAuth401ReplayProvider
|
|
3002
|
+
&& sentOAuthSnapshot
|
|
3003
|
+
&& !oauth401ReplayAttempted
|
|
3004
|
+
) {
|
|
3005
|
+
oauth401ReplayAttempted = true;
|
|
3006
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
3007
|
+
let refreshed: OAuthAccessSnapshot;
|
|
3008
|
+
try {
|
|
3009
|
+
refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
cleanupUpstreamAbort();
|
|
3012
|
+
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
3013
|
+
}
|
|
3014
|
+
sentOAuthSnapshot = refreshed;
|
|
3015
|
+
if (route.providerName === "kiro") {
|
|
3016
|
+
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
3017
|
+
}
|
|
3018
|
+
const refreshedProvider = resolveProviderTransport(
|
|
3019
|
+
route.providerName,
|
|
3020
|
+
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
3021
|
+
parsed.options.promptCacheKey,
|
|
3022
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
3023
|
+
);
|
|
3024
|
+
route.provider = refreshedProvider;
|
|
3025
|
+
invalidateSameTargetRequest();
|
|
3026
|
+
activeAdapter = resolveAdapter(
|
|
3027
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
|
|
3028
|
+
config.cacheRetention,
|
|
3029
|
+
);
|
|
3030
|
+
const result = await rebuildAndRefetch("oauth-401");
|
|
3031
|
+
if ("failed" in result) return result.failed;
|
|
3032
|
+
upstreamResponse = result;
|
|
3033
|
+
continue recovery;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
|
|
3037
|
+
// 429 itself (it retries 5xx only), and single-key pools cannot use the failover below,
|
|
3038
|
+
// so wait (Retry-After or the fixed interval) and replay the IDENTICAL request on the
|
|
3039
|
+
// same key first. Pre-stream only: a 429 arrives before any bytes are relayed, so the
|
|
3040
|
+
// replay is lossless. Runs before key failover so "primary-first" setups keep the same
|
|
3041
|
+
// key on rate-limit blips; only after the attempts are exhausted does failover run.
|
|
3042
|
+
while (
|
|
3043
|
+
upstreamResponse.status === 429
|
|
3044
|
+
&& rateLimitPolicy !== null
|
|
3045
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
3046
|
+
) {
|
|
3047
|
+
rateLimitRetries += 1;
|
|
3048
|
+
// Release unread body + deliberate wait via the shared same-target helper.
|
|
3049
|
+
const retryAfterHeader = upstreamResponse.headers.get("retry-after");
|
|
3050
|
+
try {
|
|
3051
|
+
for await (const _ of prepareSameTarget429Wait({
|
|
3052
|
+
body: upstreamResponse.body,
|
|
3053
|
+
signal: options.abortSignal,
|
|
3054
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
3055
|
+
})) {
|
|
3056
|
+
// pre-stream: no stall watchdog to feed
|
|
3057
|
+
}
|
|
3058
|
+
} catch {
|
|
3059
|
+
cleanupUpstreamAbort();
|
|
3060
|
+
upstream.abort();
|
|
3061
|
+
return clientCancelledResponse();
|
|
3062
|
+
}
|
|
3063
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
3064
|
+
// replay so an adapter never starts work for a request the client already abandoned.
|
|
3065
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
3066
|
+
cleanupUpstreamAbort();
|
|
3067
|
+
upstream.abort();
|
|
3068
|
+
return clientCancelledResponse();
|
|
3069
|
+
}
|
|
3070
|
+
const result = await rebuildAndRefetch("rate-limit-429");
|
|
3071
|
+
if ("failed" in result) return result.failed;
|
|
3072
|
+
upstreamResponse = result;
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
3076
|
+
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
3077
|
+
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
3078
|
+
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
3079
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
3080
|
+
retryAfter: upstreamResponse.headers.get("retry-after"),
|
|
3081
|
+
now: Date.now(),
|
|
3082
|
+
attemptedKey: route.provider.apiKey,
|
|
3083
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
3084
|
+
});
|
|
3085
|
+
if (!rotated) break;
|
|
3086
|
+
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
3087
|
+
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
3088
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
3089
|
+
route.provider = rotated;
|
|
3090
|
+
invalidateSameTargetRequest();
|
|
3091
|
+
activeAdapter = resolveAdapter(
|
|
3092
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
3093
|
+
config.cacheRetention,
|
|
3094
|
+
);
|
|
3095
|
+
const result = await rebuildAndRefetch("key-429");
|
|
3096
|
+
if ("failed" in result) return result.failed;
|
|
3097
|
+
upstreamResponse = result;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
|
|
3101
|
+
// with another eligible OAuth account (bounded per request). Disabled by default.
|
|
3102
|
+
while (
|
|
3103
|
+
upstreamResponse.status === 429
|
|
3104
|
+
&& anthropicPoolAccountId
|
|
3105
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
3106
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
3107
|
+
) {
|
|
3108
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
3109
|
+
config,
|
|
3110
|
+
anthropicPoolAccountId,
|
|
3111
|
+
upstreamResponse.headers.get("retry-after"),
|
|
3112
|
+
anthropicSessionKey,
|
|
3113
|
+
);
|
|
3114
|
+
if (!nextAccountId) break;
|
|
3115
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
3116
|
+
try {
|
|
3117
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
3118
|
+
anthropicPoolAccountId = nextAccountId;
|
|
3119
|
+
anthropicPoolFailovers += 1;
|
|
3120
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
3121
|
+
invalidateSameTargetRequest();
|
|
3122
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
3123
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
3124
|
+
activeAdapter = resolveAdapter(
|
|
3125
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
3126
|
+
config.cacheRetention,
|
|
3127
|
+
);
|
|
3128
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
3129
|
+
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
3130
|
+
if ("failed" in result) return result.failed;
|
|
3131
|
+
upstreamResponse = result;
|
|
3132
|
+
} catch {
|
|
3133
|
+
break;
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
// Anthropic 413 request_too_large: rebuild once with every image one tier lower
|
|
3137
|
+
// (spiral guard: single attempt). The biased response re-enters the 429 check above.
|
|
3138
|
+
if (shouldAttemptImageTierRetry({
|
|
3139
|
+
status: upstreamResponse.status,
|
|
3140
|
+
adapterName: activeAdapter.name,
|
|
3141
|
+
parsed,
|
|
3142
|
+
alreadyAttempted: imageRetryAttempted,
|
|
3143
|
+
})) {
|
|
3144
|
+
imageRetryAttempted = true;
|
|
3145
|
+
imageTierBias = 1;
|
|
3146
|
+
invalidateSameTargetRequest();
|
|
3147
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
3148
|
+
const result = await rebuildAndRefetch("image-413");
|
|
3149
|
+
if ("failed" in result) return result.failed;
|
|
3150
|
+
upstreamResponse = result;
|
|
3151
|
+
continue recovery;
|
|
3152
|
+
}
|
|
3153
|
+
break;
|
|
3154
|
+
}
|
|
3155
|
+
if (!upstreamResponse.ok) {
|
|
3156
|
+
if (options.comboAttempt) {
|
|
3157
|
+
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
|
|
3158
|
+
.finally(cleanupUpstreamAbort);
|
|
3159
|
+
options.onConsumedComboFailure?.(failure);
|
|
3160
|
+
return failure.response;
|
|
3161
|
+
}
|
|
3162
|
+
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
3163
|
+
cleanupUpstreamAbort();
|
|
3164
|
+
if (!isFixedCodexAccount(authCtx)) {
|
|
3165
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
3166
|
+
req.headers,
|
|
3167
|
+
subagentQuotaFailureModel,
|
|
3168
|
+
upstreamResponse.status === 429 || upstreamResponse.status === 402
|
|
3169
|
+
? upstreamResponse.status
|
|
3170
|
+
: `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
3171
|
+
config,
|
|
3172
|
+
subagentFallbackAccountId,
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
3175
|
+
// Upstreams occasionally echo request details in error bodies — scrub token-shaped
|
|
3176
|
+
// material before it reaches the client-facing error surface.
|
|
3177
|
+
const message = `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`;
|
|
3178
|
+
const retryAfter = resolveClientRetryAfter({
|
|
3179
|
+
status: upstreamResponse.status,
|
|
3180
|
+
message,
|
|
3181
|
+
upstreamRetryAfter: upstreamResponse.headers.get("retry-after"),
|
|
3182
|
+
});
|
|
3183
|
+
return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
|
|
3184
|
+
...(retryAfter !== undefined ? { retryAfter } : {}),
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
3190
|
+
|
|
3191
|
+
// Anthropic-only: one bounded internal continuation re-ask for clean end_turn turns that
|
|
3192
|
+
// announced an edit without emitting a tool call.
|
|
3193
|
+
const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction;
|
|
3194
|
+
/**
|
|
3195
|
+
* One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the
|
|
3196
|
+
* continuation on a 429 with the same-key retry budget (hoisted per request), then falls
|
|
3197
|
+
* back to key/account failover; a failure becomes an in-stream adapter error so the client
|
|
3198
|
+
* never sees a second hidden HTTP response or an unbounded retry loop.
|
|
3199
|
+
*/
|
|
3200
|
+
const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator<AdapterEvent> {
|
|
3201
|
+
let response: Response | undefined;
|
|
3202
|
+
// One-shot recovery label for the next top-of-loop continuation send after a failover rotation.
|
|
3203
|
+
let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined;
|
|
3204
|
+
/**
|
|
3205
|
+
* Build and fetch one terminal-guard continuation. `recoveryKind` tags same-target and
|
|
3206
|
+
* failover sends (`rate-limit-429`, `key-429`, `anthropic-oauth-429`, `image-413`); the
|
|
3207
|
+
* adapter rebuild is deterministic for the same parsed request (tests assert byte-identical
|
|
3208
|
+
* replays).
|
|
3209
|
+
*/
|
|
3210
|
+
const fetchContinuation = async (recoveryKind?: AttemptRecoveryKind): Promise<Response> => {
|
|
3211
|
+
let continuationRequest: AdapterRequest | undefined;
|
|
3212
|
+
if (sameTargetRequest !== undefined && sameTargetParsed === nextParsed && sameTargetToken === transportToken) {
|
|
3213
|
+
// Same target (key/adapter/parsed/tier unchanged): replay the exact cached request.
|
|
3214
|
+
continuationRequest = sameTargetRequest;
|
|
3215
|
+
} else {
|
|
3216
|
+
try {
|
|
3217
|
+
continuationRequest = await activeAdapter.buildRequest(nextParsed, {
|
|
3218
|
+
headers: selectedForwardHeaders,
|
|
3219
|
+
translatorBudget,
|
|
3220
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
3221
|
+
});
|
|
3222
|
+
recordAdapterReasoning(logCtx, continuationRequest);
|
|
3223
|
+
} catch (err) {
|
|
3224
|
+
// The main body is already streaming, so there is no HTTP error surface: release
|
|
3225
|
+
// any partial body observation and surface the failure as an in-stream error via
|
|
3226
|
+
// the outer catch (no upstream.abort() — that would kill the live body stream).
|
|
3227
|
+
continuationRequest?.releaseBodyObservation?.();
|
|
3228
|
+
throw err;
|
|
3229
|
+
}
|
|
3230
|
+
sameTargetRequest = continuationRequest;
|
|
3231
|
+
sameTargetParsed = nextParsed;
|
|
3232
|
+
sameTargetToken = transportToken;
|
|
3233
|
+
}
|
|
3234
|
+
// Both branches assign the request (the build catch rethrows), so capture it in a
|
|
3235
|
+
// const for the fetch callback and finally below — a `let` read inside a nested
|
|
3236
|
+
// function keeps its undefined half, which would break the byte-identical replay.
|
|
3237
|
+
const builtContinuationRequest = continuationRequest;
|
|
3238
|
+
const continuationEstimate = typeof builtContinuationRequest.usageLog?.inputTokens === "number"
|
|
3239
|
+
? builtContinuationRequest.usageLog.inputTokens
|
|
3240
|
+
: undefined;
|
|
3241
|
+
if (continuationEstimate !== undefined) logCtx.usageLogInputTokens = continuationEstimate;
|
|
3242
|
+
// Optional recovery label for same-target / failover continuation sends.
|
|
3243
|
+
const replayKind: AttemptRecoveryKind | undefined = recoveryKind;
|
|
3244
|
+
try {
|
|
3245
|
+
if (activeAdapter.fetchResponse) {
|
|
3246
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind);
|
|
3247
|
+
return await activeAdapter.fetchResponse(builtContinuationRequest, {
|
|
3248
|
+
abortSignal: upstream.signal,
|
|
3249
|
+
timeoutMs: connectMs,
|
|
3250
|
+
stream: nextParsed.stream,
|
|
3251
|
+
});
|
|
3252
|
+
}
|
|
3253
|
+
return await fetchWithResetRetry(
|
|
3254
|
+
recovery => {
|
|
3255
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind);
|
|
3256
|
+
return fetchWithHeaderTimeout(
|
|
3257
|
+
builtContinuationRequest.url,
|
|
3258
|
+
applyUpstreamRecoveryInit({
|
|
3259
|
+
method: builtContinuationRequest.method,
|
|
3260
|
+
headers: builtContinuationRequest.headers,
|
|
3261
|
+
body: builtContinuationRequest.body,
|
|
3262
|
+
}, recovery),
|
|
3263
|
+
upstream.signal,
|
|
3264
|
+
connectMs,
|
|
3265
|
+
nextParsed.stream,
|
|
3266
|
+
providerFetch(route.provider),
|
|
3267
|
+
);
|
|
3268
|
+
},
|
|
3269
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) },
|
|
3270
|
+
);
|
|
3271
|
+
} finally {
|
|
3272
|
+
builtContinuationRequest.releaseBodyObservation?.();
|
|
3273
|
+
}
|
|
3274
|
+
};
|
|
3275
|
+
while (true) {
|
|
3276
|
+
try {
|
|
3277
|
+
const recoveryKind = nextContinuationRecoveryKind;
|
|
3278
|
+
nextContinuationRecoveryKind = undefined;
|
|
3279
|
+
response = await fetchContinuation(recoveryKind);
|
|
3280
|
+
} catch (error) {
|
|
3281
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
3282
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
3283
|
+
} else {
|
|
3284
|
+
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
3285
|
+
}
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`) before key/account failover:
|
|
3290
|
+
// a primary-key rate-limit blip replays on the SAME key, matching the main recovery
|
|
3291
|
+
// loop; only after the attempts are exhausted does the continuation fail over.
|
|
3292
|
+
while (
|
|
3293
|
+
response.status === 429
|
|
3294
|
+
&& rateLimitPolicy !== null
|
|
3295
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
3296
|
+
) {
|
|
3297
|
+
rateLimitRetries += 1;
|
|
3298
|
+
// Release unread body + heartbeat-fed wait via the shared same-target helper.
|
|
3299
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
3300
|
+
try {
|
|
3301
|
+
yield* prepareSameTarget429Wait({
|
|
3302
|
+
body: response.body,
|
|
3303
|
+
// Listen on the upstream signal: once the SSE body is being streamed, a client
|
|
3304
|
+
// cancel aborts `upstream` through the bridge, and upstream is also linked from
|
|
3305
|
+
// options.abortSignal — so this covers both cancellation paths.
|
|
3306
|
+
signal: upstream.signal,
|
|
3307
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
3308
|
+
heartbeatIntervalMs: Math.min(10_000, Math.max(250, stallTimeoutMs / 2)),
|
|
3309
|
+
});
|
|
3310
|
+
} catch {
|
|
3311
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
3312
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
3313
|
+
} else {
|
|
3314
|
+
yield { type: "error", message: "Provider continuation failed: retry wait interrupted" };
|
|
3315
|
+
}
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
3319
|
+
// replay so the continuation never starts work for a request the client abandoned.
|
|
3320
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
3321
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
try {
|
|
3325
|
+
response = await fetchContinuation("rate-limit-429");
|
|
3326
|
+
} catch (error) {
|
|
3327
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
3328
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
3329
|
+
} else {
|
|
3330
|
+
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
3331
|
+
}
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
3337
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
3338
|
+
retryAfter: response.headers.get("retry-after"),
|
|
3339
|
+
now: Date.now(),
|
|
3340
|
+
attemptedKey: route.provider.apiKey,
|
|
3341
|
+
promptCacheKey: nextParsed.options.promptCacheKey,
|
|
3342
|
+
});
|
|
3343
|
+
if (rotated) {
|
|
3344
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
3345
|
+
route.provider = rotated;
|
|
3346
|
+
invalidateSameTargetRequest();
|
|
3347
|
+
activeAdapter = resolveAdapter(
|
|
3348
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
3349
|
+
config.cacheRetention,
|
|
3350
|
+
);
|
|
3351
|
+
nextContinuationRecoveryKind = "key-429";
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
if (
|
|
3356
|
+
response.status === 429
|
|
3357
|
+
&& anthropicPoolAccountId
|
|
3358
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
3359
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
3360
|
+
) {
|
|
3361
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
3362
|
+
config,
|
|
3363
|
+
anthropicPoolAccountId,
|
|
3364
|
+
response.headers.get("retry-after"),
|
|
3365
|
+
anthropicSessionKey,
|
|
3366
|
+
);
|
|
3367
|
+
if (nextAccountId) {
|
|
3368
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
3369
|
+
try {
|
|
3370
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
3371
|
+
anthropicPoolAccountId = nextAccountId;
|
|
3372
|
+
anthropicPoolFailovers += 1;
|
|
3373
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
3374
|
+
invalidateSameTargetRequest();
|
|
3375
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
3376
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
3377
|
+
activeAdapter = resolveAdapter(
|
|
3378
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
3379
|
+
config.cacheRetention,
|
|
3380
|
+
);
|
|
3381
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
3382
|
+
nextContinuationRecoveryKind = "anthropic-oauth-429";
|
|
3383
|
+
continue;
|
|
3384
|
+
} catch {
|
|
3385
|
+
// fall through to emit continuation error below
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
if (shouldAttemptImageTierRetry({
|
|
3390
|
+
status: response.status,
|
|
3391
|
+
adapterName: activeAdapter.name,
|
|
3392
|
+
parsed: nextParsed,
|
|
3393
|
+
alreadyAttempted: imageTierBias > 0,
|
|
3394
|
+
})) {
|
|
3395
|
+
imageTierBias = 1;
|
|
3396
|
+
invalidateSameTargetRequest();
|
|
3397
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
3398
|
+
nextContinuationRecoveryKind = "image-413";
|
|
3399
|
+
continue;
|
|
3400
|
+
}
|
|
3401
|
+
break;
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
if (!response.ok) {
|
|
3405
|
+
const errorText = await response.text().catch(() => "unknown error");
|
|
3406
|
+
yield {
|
|
3407
|
+
type: "error",
|
|
3408
|
+
status: response.status,
|
|
3409
|
+
message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
3410
|
+
};
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
try {
|
|
3415
|
+
// Protect the continuation body against a client abort landing between fetch resolution and
|
|
3416
|
+
// reader attach, exactly as the initial response is guarded above (#390/366e3053). Without
|
|
3417
|
+
// this, a client cancel during the continuation reopens the Bun fetch-to-reader abort race.
|
|
3418
|
+
const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
3419
|
+
try {
|
|
3420
|
+
if (nextParsed.stream) {
|
|
3421
|
+
yield* activeAdapter.parseStream(response, translatorBudget);
|
|
3422
|
+
} else if (activeAdapter.parseResponse) {
|
|
3423
|
+
yield* await activeAdapter.parseResponse(response, translatorBudget);
|
|
3424
|
+
} else {
|
|
3425
|
+
yield { type: "error", message: "Provider continuation does not support response parsing" };
|
|
3426
|
+
}
|
|
3427
|
+
} finally {
|
|
3428
|
+
detachContinuationBodyGuard();
|
|
3429
|
+
}
|
|
3430
|
+
} catch (error) {
|
|
3431
|
+
if (options.abortSignal?.aborted) {
|
|
3432
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
3433
|
+
} else {
|
|
3434
|
+
yield { type: "error", message: `Provider continuation parse failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
};
|
|
3438
|
+
|
|
3439
|
+
if (parsed.stream) {
|
|
3440
|
+
const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget);
|
|
3441
|
+
const eventStream = terminalGuardEnabled
|
|
3442
|
+
? guardTerminalEventStream({
|
|
3443
|
+
parsed,
|
|
3444
|
+
firstEvents: initialEventStream,
|
|
3445
|
+
adapterName: activeAdapter.name,
|
|
3446
|
+
maxAutoContinuations: 1,
|
|
3447
|
+
continuation: fetchTerminalGuardContinuation,
|
|
3448
|
+
})
|
|
3449
|
+
: initialEventStream;
|
|
3450
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3451
|
+
const sseStream = bridgeToResponsesSSE(
|
|
3452
|
+
eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
3453
|
+
() => upstream.abort(), 2_000,
|
|
3454
|
+
{
|
|
3455
|
+
translatorBudget,
|
|
3456
|
+
replayCacheScope: parsed._clientThreadId ?? "global",
|
|
3457
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
3458
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
3459
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
3460
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
3461
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
3462
|
+
onUsage: usage => {
|
|
3463
|
+
// Raw adapter usage, pre wire-normalization (see the runTurn branch above).
|
|
3464
|
+
logCtx.usageFromBridge = true;
|
|
3465
|
+
if (usage) {
|
|
3466
|
+
logCtx.usage = usage;
|
|
3467
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
3468
|
+
}
|
|
3469
|
+
},
|
|
3470
|
+
// Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
|
|
3471
|
+
// PRE-compaction history, and a later previous_response_id expansion would rehydrate the
|
|
3472
|
+
// giant stale chain Codex just replaced.
|
|
3473
|
+
...(routedCompaction ? {} : {
|
|
3474
|
+
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) =>
|
|
3475
|
+
rememberResponseState(
|
|
3476
|
+
parsed._rawBody,
|
|
3477
|
+
response,
|
|
3478
|
+
continuationStateForResponse(providerState),
|
|
3479
|
+
activeAdapter.name === "kiro" ? { force: true } : undefined,
|
|
3480
|
+
),
|
|
3481
|
+
}),
|
|
3482
|
+
},
|
|
3483
|
+
);
|
|
3484
|
+
const bridgeTurnAc = new AbortController();
|
|
3485
|
+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort, options.turnAdmissionLease);
|
|
3486
|
+
return new Response(trackedSse, {
|
|
3487
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
if (activeAdapter.parseResponse) {
|
|
3492
|
+
let events: AdapterEvent[];
|
|
3493
|
+
try {
|
|
3494
|
+
const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget);
|
|
3495
|
+
if (terminalGuardEnabled) {
|
|
3496
|
+
events = [];
|
|
3497
|
+
for await (const event of guardTerminalEventStream({
|
|
3498
|
+
parsed,
|
|
3499
|
+
firstEvents: (async function* () { yield* initialEvents; })(),
|
|
3500
|
+
adapterName: activeAdapter.name,
|
|
3501
|
+
maxAutoContinuations: 1,
|
|
3502
|
+
continuation: fetchTerminalGuardContinuation,
|
|
3503
|
+
})) events.push(event);
|
|
3504
|
+
} else {
|
|
3505
|
+
events = initialEvents;
|
|
3506
|
+
}
|
|
3507
|
+
} finally {
|
|
3508
|
+
cleanupUpstreamAbort();
|
|
3509
|
+
}
|
|
3510
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
3511
|
+
let providerState: OcxProviderContinuationState | undefined;
|
|
3512
|
+
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
|
|
3513
|
+
translatorBudget,
|
|
3514
|
+
replayCacheScope: parsed._clientThreadId ?? "global",
|
|
3515
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
3516
|
+
toolNsMap,
|
|
3517
|
+
freeformToolNames,
|
|
3518
|
+
toolSearchToolNames,
|
|
3519
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
3520
|
+
onProviderState: state => { providerState = state; },
|
|
3521
|
+
onUsage: usage => {
|
|
3522
|
+
logCtx.usageFromBridge = true;
|
|
3523
|
+
if (usage) {
|
|
3524
|
+
logCtx.usage = usage;
|
|
3525
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
3526
|
+
}
|
|
3527
|
+
},
|
|
3528
|
+
});
|
|
3529
|
+
// See the streaming branch: compaction turns skip the continuation cache.
|
|
3530
|
+
if (!routedCompaction) {
|
|
3531
|
+
rememberResponseState(
|
|
3532
|
+
parsed._rawBody,
|
|
3533
|
+
json,
|
|
3534
|
+
continuationStateForResponse(providerState),
|
|
3535
|
+
activeAdapter.name === "kiro" ? { force: true } : undefined,
|
|
3536
|
+
);
|
|
3537
|
+
}
|
|
3538
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
return formatErrorResponse(400, "invalid_request_error", "Non-streaming not supported by this adapter");
|
|
3542
|
+
} finally {
|
|
3543
|
+
if (pendingHostAdmissionLease) {
|
|
3544
|
+
releaseUpstreamHostAdmission(pendingHostAdmissionLease);
|
|
3545
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
|
|
3551
|
+
|
|
3552
|
+
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void {
|
|
3553
|
+
if (!signal) return () => {};
|
|
3554
|
+
if (signal.aborted) {
|
|
3555
|
+
upstream.abort(signal.reason);
|
|
3556
|
+
return () => {};
|
|
3557
|
+
}
|
|
3558
|
+
const onAbort = () => upstream.abort(signal.reason);
|
|
3559
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3560
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
3561
|
+
}
|