@iislee/opencodex 2.11.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +109 -0
- package/README.md +114 -19
- package/bin/ocx.mjs +164 -36
- package/bin/package-main.mjs +1 -1
- package/gui/dist/assets/index-BF38heuV.js +104 -0
- package/gui/dist/assets/index-DMiI18Kv.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/commandcode-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +19 -10
- package/src/adapters/anthropic-output-schema.ts +137 -0
- package/src/adapters/anthropic.ts +376 -52
- package/src/adapters/base.ts +54 -7
- package/src/adapters/client-fingerprint.ts +18 -12
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +601 -0
- package/src/adapters/cursor/checkpoint-store.ts +303 -0
- package/src/adapters/cursor/cursor-errors.ts +129 -6
- package/src/adapters/cursor/discovery.ts +61 -4
- package/src/adapters/cursor/effort-map.ts +27 -3
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/h2-pool.ts +123 -0
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/images.ts +704 -0
- package/src/adapters/cursor/live-models.ts +174 -58
- package/src/adapters/cursor/live-transport.ts +609 -170
- package/src/adapters/cursor/native-exec-common.ts +23 -2
- package/src/adapters/cursor/native-exec-fs.ts +10 -7
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +0 -1
- package/src/adapters/cursor/native-exec.ts +101 -14
- package/src/adapters/cursor/protobuf-events.ts +829 -11
- package/src/adapters/cursor/protobuf-request.ts +383 -65
- package/src/adapters/cursor/request-builder.ts +237 -31
- package/src/adapters/cursor/tool-definitions.ts +242 -12
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport.ts +22 -0
- package/src/adapters/cursor/types.ts +28 -1
- package/src/adapters/cursor.ts +133 -7
- package/src/adapters/google-antigravity-replay.ts +558 -34
- package/src/adapters/google-antigravity-wire.ts +43 -10
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +618 -74
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +92 -54
- package/src/adapters/mimo-free.ts +17 -0
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +1311 -336
- package/src/adapters/openai-responses-url.ts +16 -0
- package/src/adapters/openai-responses.ts +830 -56
- package/src/adapters/registry.ts +175 -0
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +104 -21
- package/src/adapters/xai-web-search.ts +185 -0
- package/src/bridge.ts +428 -61
- package/src/chat/inbound.ts +43 -19
- package/src/chat/outbound.ts +82 -26
- package/src/claude/agents-inject.ts +32 -9
- package/src/claude/context-windows.ts +21 -5
- package/src/claude/desktop-3p.ts +243 -9
- package/src/claude/gateway-cache.ts +41 -4
- package/src/claude/inbound.ts +72 -3
- package/src/claude/model-info.ts +38 -15
- package/src/claude/outbound.ts +70 -16
- package/src/cli/account-api.ts +35 -1
- package/src/cli/account-auth.ts +33 -6
- package/src/cli/account-catalog-refresh.ts +14 -0
- package/src/cli/account-extended.ts +389 -2
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +30 -6
- package/src/cli/agent.ts +45 -1
- package/src/cli/claude-agent-startup-sync.ts +73 -0
- package/src/cli/claude-desktop.ts +29 -4
- package/src/cli/claude.ts +103 -34
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +76 -0
- package/src/cli/combo.ts +8 -0
- package/src/cli/config-command.ts +74 -10
- package/src/cli/dispatch.ts +593 -0
- package/src/cli/doctor.ts +315 -43
- package/src/cli/ensure-desired-integrations.ts +152 -0
- package/src/cli/export-command.ts +46 -20
- package/src/cli/help.ts +20 -271
- package/src/cli/index.ts +378 -519
- package/src/cli/init.ts +4 -17
- package/src/cli/integrations.ts +120 -2
- package/src/cli/lab.ts +607 -0
- package/src/cli/launcher-context.ts +77 -0
- package/src/cli/minimax.ts +497 -0
- package/src/cli/models-runtime.ts +35 -2
- package/src/cli/models.ts +100 -14
- package/src/cli/observe.ts +92 -3
- package/src/cli/opencode.ts +4 -2
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/provider.ts +24 -3
- package/src/cli/ready.ts +301 -0
- package/src/cli/registry.ts +437 -0
- package/src/cli/root.ts +86 -0
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +10 -3
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/cli/v2.ts +105 -10
- package/src/cli.ts +1 -1
- package/src/clients/config-export.ts +1358 -21
- package/src/codex/account-label.ts +14 -1
- package/src/codex/account-lifecycle.ts +130 -13
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/account-store.ts +29 -2
- package/src/codex/account-usability.ts +25 -2
- package/src/codex/admission.ts +256 -0
- package/src/codex/affinity-debug.ts +162 -0
- package/src/codex/app-server-processes.ts +493 -106
- package/src/codex/app-server-restart-service.ts +232 -0
- package/src/codex/auth-api.ts +849 -242
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/auth-context.ts +345 -32
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +67 -0
- package/src/codex/catalog/aggregation.ts +68 -10
- package/src/codex/catalog/bundled.ts +331 -33
- package/src/codex/catalog/effort.ts +121 -30
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/kinds.ts +2 -0
- package/src/codex/catalog/metadata.ts +529 -45
- package/src/codex/catalog/native-models.ts +72 -0
- package/src/codex/catalog/parsing.ts +224 -30
- package/src/codex/catalog/provider-fetch.ts +1460 -134
- package/src/codex/catalog/sync.ts +1449 -186
- package/src/codex/catalog-admission.ts +199 -0
- package/src/codex/catalog-refresh-status.ts +105 -0
- package/src/codex/catalog-write-serialization.ts +242 -0
- package/src/codex/catalog.ts +6 -3
- package/src/codex/codex-write-lock.ts +384 -0
- package/src/codex/convergence-types.ts +614 -0
- package/src/codex/convergence.ts +651 -0
- package/src/codex/coordinator-doctor.ts +332 -0
- package/src/codex/custom-model-catalog-migration.ts +176 -0
- package/src/codex/desired-state.ts +230 -0
- package/src/codex/features.ts +636 -39
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +407 -0
- package/src/codex/history-lock.ts +242 -0
- package/src/codex/history-migration-guardian.ts +26 -20
- package/src/codex/history-provider.ts +231 -28
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +220 -0
- package/src/codex/inject-coordination.ts +290 -0
- package/src/codex/inject.ts +1073 -152
- package/src/codex/injected-marker.ts +37 -3
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +66 -4
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +88 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/main-account.ts +29 -1
- package/src/codex/management-convergence.ts +167 -0
- package/src/codex/model-cache.ts +56 -10
- package/src/codex/model-entitlements.ts +353 -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 +178 -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 +492 -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 +682 -0
- package/src/codex/paths.ts +80 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +40 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/project-config-warnings.ts +20 -6
- package/src/codex/prompt-journal.ts +352 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +298 -0
- package/src/codex/quota.ts +175 -13
- package/src/codex/refresh.ts +11 -2
- package/src/codex/reset-credit-recovery.ts +1044 -0
- package/src/codex/routing.ts +505 -94
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +1009 -28
- package/src/codex/subagent-model-fallback.ts +350 -35
- package/src/codex/sync.ts +191 -2
- package/src/codex/transition-state.ts +612 -0
- package/src/codex/upstream-host-health.ts +368 -0
- package/src/codex/user-identity.ts +557 -0
- package/src/codex/warmup.ts +187 -81
- package/src/codex/write-coordination.ts +114 -0
- package/src/combos/failover.ts +20 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/request.ts +32 -0
- package/src/combos/types.ts +81 -9
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +1762 -140
- package/src/generated/compatibility-version.json +3164 -0
- package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
- package/src/grok/inject.ts +16 -5
- package/src/grok/inspect.ts +45 -0
- package/src/grok/sync.ts +2 -2
- package/src/images/loop.ts +152 -29
- package/src/images/plan.ts +23 -13
- package/src/integrations/config-io.ts +269 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/mutation-flight.ts +71 -0
- package/src/integrations/native/ownership-preflight.ts +202 -0
- package/src/integrations/omp-yaml-source.ts +358 -0
- package/src/integrations/owned-refresh.ts +74 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +159 -0
- package/src/integrations/serialize.ts +314 -0
- package/src/integrations/state.ts +361 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +691 -0
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/automation/budgets.ts +78 -0
- package/src/lab/automation/config-persistence.ts +256 -0
- package/src/lab/automation/constants.ts +39 -0
- package/src/lab/automation/cooldown.ts +103 -0
- package/src/lab/automation/dispatch.ts +211 -0
- package/src/lab/automation/index.ts +13 -0
- package/src/lab/automation/orchestrator.ts +499 -0
- package/src/lab/automation/persistence.ts +512 -0
- package/src/lab/automation/planner.ts +371 -0
- package/src/lab/automation/policy.ts +136 -0
- package/src/lab/automation/queue.ts +191 -0
- package/src/lab/automation/recovery.ts +24 -0
- package/src/lab/automation/route-context.ts +21 -0
- package/src/lab/automation/run-key.ts +44 -0
- package/src/lab/automation/runs-query.ts +34 -0
- package/src/lab/automation/types.ts +160 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +64 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +164 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +68 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +214 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +229 -0
- package/src/lab/events/validate.ts +781 -0
- package/src/lab/fabric/constants.ts +40 -0
- package/src/lab/fabric/executor.ts +492 -0
- package/src/lab/fabric/index.ts +80 -0
- package/src/lab/fabric/manifest.ts +222 -0
- package/src/lab/fabric/observe.ts +489 -0
- package/src/lab/fabric/patch.ts +79 -0
- package/src/lab/fabric/producer-child.ts +139 -0
- package/src/lab/fabric/producer-isolate.ts +276 -0
- package/src/lab/fabric/producer-protocol.ts +61 -0
- package/src/lab/fabric/scratch.ts +439 -0
- package/src/lab/fabric/subject.ts +106 -0
- package/src/lab/fabric/types.ts +134 -0
- package/src/lab/fabric/verifier.ts +98 -0
- package/src/lab/index.ts +54 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +310 -0
- package/src/lab/ledger/store.ts +532 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +336 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +118 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +301 -0
- package/src/lab/observe/from-live.ts +117 -0
- package/src/lab/paths.ts +153 -0
- package/src/lab/projection/rebuild.ts +495 -0
- package/src/lab/projection/schema.ts +135 -0
- package/src/lab/projection/verdicts.ts +474 -0
- package/src/lab/projection/verification.ts +412 -0
- package/src/lab/public/bundle.ts +217 -0
- package/src/lab/public/community-authority.ts +175 -0
- package/src/lab/public/community-files.ts +29 -0
- package/src/lab/public/community.ts +479 -0
- package/src/lab/public/file-safety.ts +155 -0
- package/src/lab/public/ids.ts +26 -0
- package/src/lab/public/index.ts +16 -0
- package/src/lab/public/mutation-lock.ts +424 -0
- package/src/lab/public/operator.ts +353 -0
- package/src/lab/public/origin-purge.ts +79 -0
- package/src/lab/public/origin.ts +203 -0
- package/src/lab/public/privacy.ts +143 -0
- package/src/lab/public/private-file.ts +261 -0
- package/src/lab/public/project.ts +124 -0
- package/src/lab/public/purge-test-fault.ts +21 -0
- package/src/lab/public/purge.ts +223 -0
- package/src/lab/public/registry.ts +44 -0
- package/src/lab/public/revocation.ts +252 -0
- package/src/lab/public/signature.ts +243 -0
- package/src/lab/public/storage.ts +105 -0
- package/src/lab/public/strict-json.ts +206 -0
- package/src/lab/public/time.ts +26 -0
- package/src/lab/public/types.ts +172 -0
- package/src/lab/public/validate.ts +391 -0
- package/src/lab/query/catalog.ts +101 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/freshness.ts +53 -0
- package/src/lab/query/index.ts +45 -0
- package/src/lab/query/latest-observation.ts +59 -0
- package/src/lab/query/passive-production.ts +159 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +112 -0
- package/src/lab/subject/protocol-subject.ts +80 -0
- package/src/lab/subject/route-subject.ts +74 -0
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/bounded-body.ts +153 -9
- package/src/lib/bun-runtime.ts +125 -12
- package/src/lib/bun-stream-caps.ts +13 -9
- package/src/lib/codex-restart-contract.ts +120 -0
- package/src/lib/config-ownership.ts +6 -2
- package/src/lib/destination-policy.ts +65 -1
- package/src/lib/errors.ts +44 -2
- package/src/lib/fabric-task-execution-authority.ts +7 -0
- package/src/lib/fabric-task-host.ts +29 -0
- package/src/lib/lab-activation.ts +223 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +56 -0
- package/src/lib/lab-live-route-production.ts +130 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/local-management-attestation.ts +51 -0
- package/src/lib/local-management-capability.ts +100 -0
- package/src/lib/local-provider-reload-contract.ts +100 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/pinned-http.ts +145 -26
- package/src/lib/process-control.ts +4 -1
- package/src/lib/provider-outbound.ts +49 -9
- package/src/lib/redact.ts +419 -3
- package/src/lib/self-launch-argv.ts +15 -0
- package/src/lib/server-resource-ownership.ts +71 -0
- package/src/lib/shadow-call.ts +35 -4
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/state-store-registrations.ts +10 -2
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/tool-argument-integers.ts +202 -0
- package/src/lib/translator-budget.ts +44 -0
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-reachability.ts +95 -0
- package/src/lib/upstream-retry.ts +156 -3
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-elevation.ts +70 -2
- package/src/lib/windows-secret-acl.ts +409 -69
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +341 -0
- package/src/lib/winsw.ts +33 -5
- package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
- package/src/oauth/account-import/index.ts +15 -0
- package/src/oauth/account-import/parser.ts +83 -0
- package/src/oauth/account-import/registry.ts +18 -0
- package/src/oauth/account-import/service.ts +75 -0
- package/src/oauth/account-import/types.ts +91 -0
- package/src/oauth/anthropic.ts +12 -1
- package/src/oauth/callback-server.ts +8 -2
- package/src/oauth/chatgpt.ts +12 -1
- package/src/oauth/command-code.ts +239 -0
- package/src/oauth/cursor.ts +46 -5
- package/src/oauth/google-antigravity.ts +35 -3
- package/src/oauth/health.ts +20 -12
- package/src/oauth/index.ts +398 -66
- package/src/oauth/key-providers.ts +16 -0
- package/src/oauth/kimi.ts +16 -2
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/log.ts +3 -1
- package/src/oauth/login-cli.ts +88 -28
- package/src/oauth/nous.ts +798 -0
- package/src/oauth/store.ts +119 -21
- package/src/oauth/token-guardian.ts +9 -3
- package/src/pi/models.ts +2 -2
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/antigravity-models.ts +521 -31
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/codex-capacity.ts +292 -0
- package/src/providers/command-code-efforts.ts +144 -0
- package/src/providers/context-cap.ts +22 -5
- package/src/providers/cursor-pool.ts +72 -0
- package/src/providers/derive.ts +253 -6
- package/src/providers/fastwire.ts +501 -0
- package/src/providers/free-directory.ts +10 -7
- package/src/providers/google-vertex-location.ts +14 -0
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery-limits.ts +16 -0
- package/src/providers/model-discovery.ts +115 -22
- package/src/providers/model-rename-migration.ts +255 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-sidecar.ts +72 -4
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/openai-tiers.ts +119 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +29 -0
- package/src/providers/quota.ts +1319 -38
- package/src/providers/registry.ts +1429 -111
- package/src/providers/request-pacing.ts +310 -0
- package/src/providers/service-tier.ts +277 -0
- package/src/providers/slug-codec.ts +42 -6
- package/src/providers/static-model-discovery.ts +86 -0
- package/src/providers/xai-responses-opt-in.ts +15 -0
- package/src/providers/xai-transport.ts +11 -4
- package/src/reasoning-effort.ts +49 -1
- package/src/responses/compaction.ts +26 -1
- package/src/responses/custom-tool-compat.ts +266 -0
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/namespace-tool-compat.ts +355 -0
- package/src/responses/parser.ts +220 -38
- package/src/responses/provider-continuation.ts +98 -0
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +426 -0
- package/src/responses/schema.ts +7 -1
- package/src/responses/spill-store.ts +75 -10
- package/src/responses/state.ts +565 -27
- package/src/responses/thought-signature-replay.ts +347 -0
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +366 -30
- package/src/routing/analytics.ts +378 -0
- package/src/routing/capability.ts +244 -0
- package/src/routing/compatibility/assemble.ts +73 -0
- package/src/routing/compatibility/behavior.ts +278 -0
- package/src/routing/compatibility/catalog.ts +99 -0
- package/src/routing/compatibility/endpoint.ts +52 -0
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/policy.ts +181 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/routing/compatibility/reader.ts +110 -0
- package/src/routing/compatibility/subject.ts +191 -0
- package/src/routing/compatibility/types.ts +64 -0
- package/src/routing/compatibility/version.ts +104 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +495 -0
- package/src/routing/health.ts +412 -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 +547 -0
- package/src/routing/quota.ts +145 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +776 -0
- package/src/server/adapter-resolve.ts +2 -29
- package/src/server/auth-cors.ts +267 -46
- package/src/server/background-lifecycle.ts +182 -0
- package/src/server/chat-completions.ts +130 -56
- package/src/server/chat-native-sse.ts +331 -0
- package/src/server/chat-native.ts +426 -0
- package/src/server/claude-messages.ts +159 -43
- package/src/server/direct-local-http.ts +347 -0
- package/src/server/effort-policy.ts +18 -0
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +94 -12
- package/src/server/index.ts +865 -181
- package/src/server/lifecycle.ts +284 -13
- package/src/server/live.ts +136 -17
- package/src/server/local-management-read-client.ts +90 -0
- package/src/server/local-provider-reload-client.ts +137 -0
- package/src/server/management/agent-settings-routes.ts +398 -103
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/body.ts +6 -0
- package/src/server/management/combo-routes.ts +62 -24
- package/src/server/management/config-routes.ts +464 -51
- package/src/server/management/context.ts +80 -2
- package/src/server/management/integration-routes.ts +498 -0
- package/src/server/management/lab-automation-routes.ts +206 -0
- package/src/server/management/lab-routes.ts +563 -0
- package/src/server/management/logs-usage-routes.ts +101 -32
- package/src/server/management/model-routes.ts +189 -131
- package/src/server/management/model-rows.ts +163 -0
- package/src/server/management/native-integration-routes.ts +769 -0
- package/src/server/management/oauth-account-routes.ts +80 -4
- package/src/server/management/provider-capability-config.ts +48 -0
- package/src/server/management/provider-routes.ts +764 -157
- 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 +380 -0
- package/src/server/management/shared.ts +27 -11
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +288 -32
- package/src/server/management/system-routes.ts +77 -0
- package/src/server/management/usage-summary-cache.ts +9 -1
- package/src/server/management/vision-sidecar-options.ts +167 -0
- package/src/server/management/web-search-sidecar-options.ts +120 -0
- package/src/server/management-api.ts +115 -14
- package/src/server/management-auth.ts +220 -5
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +132 -5
- package/src/server/readiness.ts +99 -0
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +236 -76
- package/src/server/request-decompress.ts +113 -6
- package/src/server/request-log.ts +235 -22
- package/src/server/responses/agent-task-recovery-cache.ts +143 -0
- package/src/server/responses/agent-task-recovery.ts +465 -0
- package/src/server/responses/collaboration.ts +204 -35
- package/src/server/responses/compact.ts +442 -55
- package/src/server/responses/core.ts +2872 -331
- package/src/server/responses/empty-completion-guard.ts +276 -0
- package/src/server/responses/encrypted-payload.ts +62 -39
- package/src/server/responses/fetch-helpers.ts +79 -4
- package/src/server/responses/input-admission.ts +185 -0
- package/src/server/responses/pacing-overload.ts +13 -0
- package/src/server/responses/policy-fallback.ts +178 -0
- package/src/server/responses/responses-field-backfill.ts +251 -0
- package/src/server/responses/terminal-guard.ts +26 -5
- package/src/server/responses/upstream-error.ts +5 -0
- package/src/server/responses/ws-upstream.ts +308 -0
- package/src/server/responses-custom-tool-repair.ts +282 -0
- package/src/server/responses-item-id-repair.ts +54 -6
- package/src/server/responses-json-events.ts +90 -0
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/responses-terminal-repair.ts +342 -0
- package/src/server/responses-tool-search-repair.ts +267 -0
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/responses.ts +18 -2
- package/src/server/search.ts +78 -13
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +110 -22
- package/src/server/startup-action-control.ts +8 -1
- package/src/server/startup-health-cache.ts +19 -1
- package/src/server/system-env.ts +80 -9
- package/src/server/ws-bridge.ts +39 -38
- package/src/service-manager-probe.ts +892 -0
- package/src/service.ts +1111 -90
- package/src/sidecar/auth.ts +92 -0
- package/src/sidecar/candidates.ts +83 -0
- package/src/storage/cleanup.ts +2 -2
- package/src/storage/scanner.ts +1 -1
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +83 -9
- package/src/tray/windows.ts +43 -16
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +845 -0
- package/src/types/provider.ts +545 -0
- package/src/types/request.ts +384 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +104 -1236
- package/src/update/index.ts +32 -19
- package/src/update/job.ts +442 -67
- package/src/update/notify.ts +12 -6
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +268 -16
- package/src/usage/log.ts +606 -41
- package/src/usage/summary.ts +177 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/vision/backends.ts +97 -0
- package/src/vision/describe.ts +9 -3
- package/src/vision/eligibility.ts +250 -0
- package/src/vision/index.ts +238 -24
- package/src/vision/reasoning.ts +55 -0
- package/src/vision/routed-describe.ts +175 -0
- package/src/vision/timeout-bounds.ts +9 -0
- package/src/web-search/anthropic-executor.ts +13 -7
- package/src/web-search/backends.ts +108 -0
- package/src/web-search/exa-executor.ts +88 -0
- package/src/web-search/executor.ts +11 -3
- package/src/web-search/gemini-executor.ts +141 -0
- package/src/web-search/index.ts +150 -15
- package/src/web-search/loop.ts +279 -50
- package/src/web-search/parse.ts +125 -30
- package/src/web-search/sources.ts +60 -0
- package/src/web-search/xai-executor.ts +219 -0
- package/gui/dist/assets/index-DTpMHS4F.js +0 -67
- package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
- package/src/cli/internal-dispatch.ts +0 -20
|
@@ -1,27 +1,64 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
4
|
import { formatPassthroughUpstreamError } from "./passthrough-error";
|
|
5
|
+
import {
|
|
6
|
+
createResponsesFieldBackfillBlockRewrite,
|
|
7
|
+
backfillResponsesFieldsJson,
|
|
8
|
+
} from "./responses-field-backfill";
|
|
9
|
+
import { checkInputAdmission } from "./input-admission";
|
|
10
|
+
import { nativeContextLimits } from "../../codex/catalog";
|
|
4
11
|
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
5
12
|
import {
|
|
6
|
-
getConfigPath,
|
|
7
13
|
multiAgentGuidanceEnabled,
|
|
8
14
|
resolveEnvValue,
|
|
9
15
|
} from "../../config";
|
|
10
16
|
import { parseRequest } from "../../responses/parser";
|
|
17
|
+
import {
|
|
18
|
+
bindReasoningReplayScope,
|
|
19
|
+
commitReasoningReplayServingIdentity,
|
|
20
|
+
reasoningReplayCodexCredentialIdentity,
|
|
21
|
+
reasoningReplayDestinationIdentity,
|
|
22
|
+
durableReplayDestinationIdentity,
|
|
23
|
+
durableReplayCredentialIdentity,
|
|
24
|
+
reasoningReplayKeyCredentialIdentity,
|
|
25
|
+
reasoningReplayOAuthCredentialIdentity,
|
|
26
|
+
reasoningReplayServingIdentityChanged,
|
|
27
|
+
} from "../../responses/reasoning-replay-cache";
|
|
28
|
+
import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
|
|
11
29
|
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
|
|
12
30
|
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
|
|
13
31
|
import {
|
|
32
|
+
copyPreviousResponseReplayProvenance,
|
|
14
33
|
expandPreviousResponseInput,
|
|
34
|
+
markBodyNonPersistable,
|
|
15
35
|
previousResponseProviderState,
|
|
16
36
|
previousResponseReplayFailure,
|
|
37
|
+
previousResponseScopeMismatch,
|
|
17
38
|
rememberResponseState,
|
|
18
39
|
} from "../../responses/state";
|
|
19
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
isValidProviderContinuationOwner,
|
|
42
|
+
mergeProviderContinuationPayload,
|
|
43
|
+
providerContinuationOwnerFromReplayIdentity,
|
|
44
|
+
providerContinuationRouteScope,
|
|
45
|
+
sameProviderContinuationOwner,
|
|
46
|
+
} from "../../responses/provider-continuation";
|
|
47
|
+
import {
|
|
48
|
+
comboRouteDecisionTrace,
|
|
49
|
+
NoEligiblePolicyCandidateError,
|
|
50
|
+
routeConcreteModel,
|
|
51
|
+
routeModel,
|
|
52
|
+
type RouteResult,
|
|
53
|
+
} from "../../router";
|
|
54
|
+
import { evidenceFromBody } from "../../routing/request-evidence";
|
|
55
|
+
import { resolvePassiveRouteSubjectId } from "../passive-route-linker";
|
|
20
56
|
import {
|
|
21
57
|
advanceComboAfterFailure,
|
|
22
58
|
comboDefaultEffort,
|
|
23
59
|
comboFailureDecision,
|
|
24
60
|
comboIdFromRawBody,
|
|
61
|
+
comboRequestHasImageInput,
|
|
25
62
|
concreteComboRequestBody,
|
|
26
63
|
getCombo,
|
|
27
64
|
isComboTargetInCooldown,
|
|
@@ -34,14 +71,25 @@ import {
|
|
|
34
71
|
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
35
72
|
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
36
73
|
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
74
|
+
import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
|
|
37
75
|
import { modelInList, namespacedToolName } from "../../types";
|
|
38
|
-
import type {
|
|
76
|
+
import type {
|
|
77
|
+
AdapterEvent,
|
|
78
|
+
OcxConfig,
|
|
79
|
+
OcxParsedRequest,
|
|
80
|
+
OcxProviderConfig,
|
|
81
|
+
OcxProviderContinuationOwner,
|
|
82
|
+
OcxProviderContinuationState,
|
|
83
|
+
OcxReasoningReplayIdentity,
|
|
84
|
+
OcxUsage,
|
|
85
|
+
TierDecision,
|
|
86
|
+
} from "../../types";
|
|
39
87
|
import {
|
|
40
88
|
forceRefreshOAuthAccessSnapshot,
|
|
41
89
|
getOAuthCredentialApiBaseUrl,
|
|
42
|
-
getOAuthCredentialProjectId,
|
|
43
90
|
getValidAccessTokenForAccount,
|
|
44
91
|
getValidAccessTokenSnapshot,
|
|
92
|
+
publicOAuthAuthenticationErrorMessage,
|
|
45
93
|
type OAuthAccessSnapshot,
|
|
46
94
|
UnsupportedOAuthProviderError,
|
|
47
95
|
} from "../../oauth";
|
|
@@ -59,17 +107,22 @@ import {
|
|
|
59
107
|
} from "../../oauth/anthropic-routing";
|
|
60
108
|
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
61
109
|
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
62
|
-
import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
63
|
-
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
110
|
+
import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
111
|
+
import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
|
|
64
112
|
import {
|
|
65
113
|
applyCodexAuthContextToProvider,
|
|
114
|
+
codexPoolAffinityKey,
|
|
66
115
|
CodexAccountCooldownError,
|
|
116
|
+
codexMainProfileDrainingResponse,
|
|
67
117
|
cooldownErrorResponse,
|
|
68
118
|
CodexAuthContextError,
|
|
69
119
|
CodexDirectAuthenticationError,
|
|
120
|
+
CodexMainProfileDrainingError,
|
|
70
121
|
CodexPoolAuthenticationError,
|
|
71
122
|
CodexThreadAffinityExpiredError,
|
|
72
123
|
headersForCodexAuthContext,
|
|
124
|
+
materializeCodexUpstreamAuth,
|
|
125
|
+
CodexMainSubstitutionUnavailableError,
|
|
73
126
|
isCodexAuthContextUsable,
|
|
74
127
|
resolveCodexAuthContext,
|
|
75
128
|
codexProbeLeaseId,
|
|
@@ -78,30 +131,72 @@ import {
|
|
|
78
131
|
stripCodexRuntimeProviderFields,
|
|
79
132
|
type CodexAuthContext,
|
|
80
133
|
} from "../../codex/auth-context";
|
|
134
|
+
import {
|
|
135
|
+
entitledCodexAccountIdsForModel,
|
|
136
|
+
invalidateCodexModelEntitlementsForAccount,
|
|
137
|
+
resolveCodexModelEntitlements,
|
|
138
|
+
} from "../../codex/model-entitlements";
|
|
139
|
+
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
|
|
140
|
+
import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
|
|
81
141
|
import {
|
|
82
142
|
computeQuotaCooldown,
|
|
143
|
+
codexQuotaScopeForModel,
|
|
83
144
|
formatCodexProviderForLog,
|
|
84
145
|
previewCodexAccountForRequest,
|
|
85
146
|
recordCodexUpstreamOutcome,
|
|
86
147
|
type CodexUpstreamOutcome,
|
|
87
148
|
} from "../../codex/routing";
|
|
88
|
-
import {
|
|
149
|
+
import { codexAuthContextLogLabel } from "../../codex/account-label";
|
|
150
|
+
import {
|
|
151
|
+
applyUpstreamRecoveryInit,
|
|
152
|
+
fetchWithResetRetry,
|
|
153
|
+
fetchWithTransientRetry,
|
|
154
|
+
prepareSameTarget429Wait,
|
|
155
|
+
} from "../../lib/upstream-retry";
|
|
89
156
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
157
|
+
import type { DataPlaneAdmission } from "../auth-cors";
|
|
90
158
|
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
|
|
91
159
|
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
92
|
-
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
160
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
161
|
+
import { providerContextCap } from "../../providers/context-cap";
|
|
162
|
+
import {
|
|
163
|
+
fastPolicyForModel,
|
|
164
|
+
serviceTierSupportFromPolicy,
|
|
165
|
+
SERVICE_TIER_ADAPTERS,
|
|
166
|
+
} from "../../providers/service-tier";
|
|
167
|
+
import {
|
|
168
|
+
canonicalFastTierMarker,
|
|
169
|
+
decideTier,
|
|
170
|
+
tierObservationContext,
|
|
171
|
+
tierValueAfterDecision,
|
|
172
|
+
type ResolvedFastPolicy,
|
|
173
|
+
} from "../../providers/fastwire";
|
|
174
|
+
import {
|
|
175
|
+
RequestPacingQueueOverloadError,
|
|
176
|
+
waitForProviderRequestSlot,
|
|
177
|
+
} from "../../providers/request-pacing";
|
|
93
178
|
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
94
179
|
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
95
180
|
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
96
181
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
|
|
97
182
|
import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
|
|
98
|
-
import
|
|
99
|
-
|
|
183
|
+
import {
|
|
184
|
+
providerModelResponsesTerminalRepair,
|
|
185
|
+
providerModelResponsesUpstreamStreaming,
|
|
186
|
+
type InboundWire,
|
|
187
|
+
} from "../../providers/registry";
|
|
188
|
+
import type { AdapterRequest } from "../../adapters/base";
|
|
189
|
+
import {
|
|
190
|
+
hasKeyPoolFailover,
|
|
191
|
+
rateLimitRetryDelayMs,
|
|
192
|
+
rateLimitRetryPolicyFor,
|
|
193
|
+
rotateProviderTransportOn429,
|
|
194
|
+
} from "../../providers/key-failover";
|
|
100
195
|
import { shouldAttemptImageTierRetry } from "../image-retry";
|
|
101
196
|
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
102
197
|
import type { WsData } from "../ws-bridge";
|
|
103
|
-
import { registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
104
|
-
import { redactSecretString } from "../../lib/redact";
|
|
198
|
+
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
199
|
+
import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
|
|
105
200
|
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
106
201
|
import type { AdmissionLease } from "../../lib/admission";
|
|
107
202
|
import { supportedLadderFor } from "../effort-policy";
|
|
@@ -111,14 +206,16 @@ import {
|
|
|
111
206
|
maybePrimeSubagentQuota,
|
|
112
207
|
recordSubagentQuotaFailureForThreadSpawn,
|
|
113
208
|
} from "../../codex/subagent-model-fallback";
|
|
209
|
+
import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
|
|
114
210
|
import {
|
|
115
211
|
beginRequestAttempt,
|
|
116
|
-
catalogModelSupportsServiceTier,
|
|
117
212
|
finishRequestAttempt,
|
|
118
213
|
inspectResponseLogJson,
|
|
119
214
|
noteAttemptSend,
|
|
120
215
|
readConfiguredCodexServiceTier,
|
|
121
216
|
recordAdapterReasoning,
|
|
217
|
+
recordAdapterTier,
|
|
218
|
+
recordAdapterTierMetadata,
|
|
122
219
|
recordAttemptRequestedEffort,
|
|
123
220
|
requestLogSpeedLabel,
|
|
124
221
|
sealRequestAttemptIdentity,
|
|
@@ -143,25 +240,85 @@ import {
|
|
|
143
240
|
relayWithAbort,
|
|
144
241
|
sanitizePassthroughHeaders,
|
|
145
242
|
} from "../relay";
|
|
243
|
+
import {
|
|
244
|
+
agentTaskRecoveryConfig,
|
|
245
|
+
recoverEncryptedAgentTask,
|
|
246
|
+
} from "./agent-task-recovery";
|
|
146
247
|
import { relaySseEagerBounded } from "../relay-eager";
|
|
248
|
+
import {
|
|
249
|
+
relayResponsesSseWithTerminalRepair,
|
|
250
|
+
type ResponsesTerminalRepairScheduler,
|
|
251
|
+
} from "../responses-terminal-repair";
|
|
147
252
|
import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps";
|
|
148
253
|
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
254
|
+
import { isCodexWsUpstreamResponse, type BunRuntimeGateInput } from "./ws-upstream";
|
|
149
255
|
import {
|
|
150
256
|
createResponsesItemIdPayloadRewrite,
|
|
151
257
|
hasResponsesItemIdRepair,
|
|
258
|
+
repairResponsesJsonItemIds,
|
|
152
259
|
} from "../responses-item-id-repair";
|
|
260
|
+
import {
|
|
261
|
+
createReasoningSummaryChannelPayloadRewrite,
|
|
262
|
+
rewriteReasoningSummaryInJsonString,
|
|
263
|
+
routeUsesContentChannelReasoning,
|
|
264
|
+
} from "../responses-reasoning-summary-rewrite";
|
|
153
265
|
import {
|
|
154
266
|
createImageGenCallRestoreRewrite,
|
|
155
267
|
imageGenToolCallAliases,
|
|
156
268
|
restoreImageGenCallsInJson,
|
|
157
269
|
} from "../responses-image-gen-repair";
|
|
158
|
-
import {
|
|
270
|
+
import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite";
|
|
159
271
|
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
160
272
|
|
|
161
273
|
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
162
274
|
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
|
|
163
|
-
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./fetch-helpers";
|
|
275
|
+
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
|
|
276
|
+
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
|
|
277
|
+
import {
|
|
278
|
+
acquireUpstreamHostAdmission,
|
|
279
|
+
disableUpstreamHostCircuitForKey,
|
|
280
|
+
normalizeUpstreamHostCircuitThreshold,
|
|
281
|
+
recordUpstreamHostFailure,
|
|
282
|
+
releaseUpstreamHostAdmission,
|
|
283
|
+
resetUpstreamHostHealth,
|
|
284
|
+
upstreamHostHealthKey,
|
|
285
|
+
type UpstreamHostAdmissionLease,
|
|
286
|
+
} from "../../codex/upstream-host-health";
|
|
287
|
+
import {
|
|
288
|
+
createResponsesSnapshotBlockRewrite,
|
|
289
|
+
hasResponsesSnapshotRepair,
|
|
290
|
+
repairResponsesSnapshotJson,
|
|
291
|
+
} from "../responses-snapshot-repair";
|
|
292
|
+
import {
|
|
293
|
+
composeSseBlockRewrites,
|
|
294
|
+
composeSsePayloadRewrites,
|
|
295
|
+
payloadRewriteAsBlockRewrite,
|
|
296
|
+
relaySseWithBlockRewrite,
|
|
297
|
+
} from "../sse-payload-rewrite";
|
|
298
|
+
import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
|
|
299
|
+
import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
|
|
300
|
+
import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat";
|
|
301
|
+
import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair";
|
|
302
|
+
import {
|
|
303
|
+
createRoutedNamespaceCallRestoreRewrite,
|
|
304
|
+
NamespaceToolCollisionError,
|
|
305
|
+
restoreRoutedNamespaceCallsInJson,
|
|
306
|
+
type RoutedNamespaceToolAliases,
|
|
307
|
+
} from "../../responses/namespace-tool-compat";
|
|
308
|
+
import {
|
|
309
|
+
collectDeclaredWireToolNames,
|
|
310
|
+
createUndeclaredToolCallGuardBlockRewrite,
|
|
311
|
+
undeclaredToolCallMessage,
|
|
312
|
+
undeclaredToolCallName,
|
|
313
|
+
undeclaredToolCallNameInResponse,
|
|
314
|
+
} from "../responses-undeclared-tool-guard";
|
|
315
|
+
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
|
|
316
|
+
import { responsesJsonToSseStream } from "../responses-json-events";
|
|
164
317
|
import { guardTerminalEventStream } from "./terminal-guard";
|
|
318
|
+
import {
|
|
319
|
+
emptyCompletionRetryEnabled,
|
|
320
|
+
guardEmptyCompletionEventStream,
|
|
321
|
+
} from "./empty-completion-guard";
|
|
165
322
|
|
|
166
323
|
/**
|
|
167
324
|
* Adapters whose continuation state must survive Codex's store:false requests.
|
|
@@ -173,12 +330,13 @@ export function adapterNeedsForcedContinuation(name: string): boolean {
|
|
|
173
330
|
export function sidecarOutcomeRecorder(
|
|
174
331
|
config: OcxConfig,
|
|
175
332
|
authCtx: CodexAuthContext,
|
|
176
|
-
threadId?: string | null,
|
|
177
333
|
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
178
334
|
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
179
335
|
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
180
|
-
threadId,
|
|
336
|
+
threadId: authCtx.affinityKey,
|
|
337
|
+
fixedAccount: authCtx.fixedAccount,
|
|
181
338
|
probeLeaseId: authCtx.probeLeaseId,
|
|
339
|
+
probeQuotaScope: authCtx.probeQuotaScope,
|
|
182
340
|
writerGeneration: authCtx.writerGeneration,
|
|
183
341
|
})
|
|
184
342
|
: undefined;
|
|
@@ -186,7 +344,7 @@ export function sidecarOutcomeRecorder(
|
|
|
186
344
|
|
|
187
345
|
|
|
188
346
|
|
|
189
|
-
import { isShadowSourceModel } from "../../lib/shadow-call";
|
|
347
|
+
import { isShadowSourceModel, shadowSourceModelPrefix, shouldInterceptShadowCall } from "../../lib/shadow-call";
|
|
190
348
|
|
|
191
349
|
export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
|
|
192
350
|
|
|
@@ -196,7 +354,338 @@ export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
|
|
|
196
354
|
return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
|
|
197
355
|
}
|
|
198
356
|
|
|
357
|
+
type ContinuationOwnerRead =
|
|
358
|
+
| { kind: "missing" }
|
|
359
|
+
| { kind: "invalid" }
|
|
360
|
+
| { kind: "valid"; owner: OcxProviderContinuationOwner };
|
|
361
|
+
|
|
362
|
+
function readProviderContinuationOwner(
|
|
363
|
+
state: OcxProviderContinuationState | undefined,
|
|
364
|
+
): ContinuationOwnerRead {
|
|
365
|
+
if (!state || state.__ocxOwner === undefined) return { kind: "missing" };
|
|
366
|
+
const owner = state.__ocxOwner;
|
|
367
|
+
if (!isValidProviderContinuationOwner(owner)) return { kind: "invalid" };
|
|
368
|
+
return { kind: "valid", owner: { ...owner } };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function providerContinuationPayload(
|
|
372
|
+
state: OcxProviderContinuationState | undefined,
|
|
373
|
+
): OcxProviderContinuationState | undefined {
|
|
374
|
+
if (!state) return undefined;
|
|
375
|
+
const cloned = structuredClone(state);
|
|
376
|
+
delete cloned.__ocxOwner;
|
|
377
|
+
return Object.keys(cloned).length > 0 ? cloned : undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function bindProviderContinuationForRoute(
|
|
381
|
+
parsed: OcxParsedRequest,
|
|
382
|
+
currentOwner: OcxProviderContinuationOwner | undefined,
|
|
383
|
+
): void {
|
|
384
|
+
const candidate = parsed._providerContinuationCandidate;
|
|
385
|
+
const storedOwner = readProviderContinuationOwner(candidate);
|
|
386
|
+
const mayRestore = storedOwner.kind === "valid"
|
|
387
|
+
&& !!currentOwner
|
|
388
|
+
&& sameProviderContinuationOwner(storedOwner.owner, currentOwner);
|
|
389
|
+
const restored = mayRestore ? providerContinuationPayload(candidate) : undefined;
|
|
390
|
+
if (restored) parsed._providerContinuation = restored;
|
|
391
|
+
else delete parsed._providerContinuation;
|
|
392
|
+
const cursorConversationId = restored?.cursor?.conversationId;
|
|
393
|
+
if (cursorConversationId) parsed._cursorConversationId = cursorConversationId;
|
|
394
|
+
else delete parsed._cursorConversationId;
|
|
395
|
+
if (currentOwner) parsed._providerContinuationOwner = { ...currentOwner };
|
|
396
|
+
else delete parsed._providerContinuationOwner;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function providerContinuationDestinationIdentity(
|
|
400
|
+
parsed: OcxParsedRequest,
|
|
401
|
+
provider: OcxProviderConfig,
|
|
402
|
+
): string | undefined {
|
|
403
|
+
const kiroContext = parsed._kiroAuthContext;
|
|
404
|
+
return reasoningReplayDestinationIdentity(JSON.stringify([
|
|
405
|
+
provider.baseUrl.trim().replace(/\/+$/, ""),
|
|
406
|
+
provider.responsesPath ?? "",
|
|
407
|
+
kiroContext?.profileArn ?? "",
|
|
408
|
+
kiroContext?.apiRegion ?? "",
|
|
409
|
+
kiroContext?.ssoRegion ?? "",
|
|
410
|
+
]));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function bindRouteReasoningReplayScope(args: {
|
|
414
|
+
parsed: OcxParsedRequest;
|
|
415
|
+
providerName: string;
|
|
416
|
+
provider: OcxProviderConfig;
|
|
417
|
+
adapterName: string;
|
|
418
|
+
oauthCredentialSnapshot?: Pick<OAuthAccessSnapshot, "accountId" | "generation">;
|
|
419
|
+
codexAuthContext?: CodexAuthContext;
|
|
420
|
+
forwardHeaders?: Headers;
|
|
421
|
+
}): void {
|
|
422
|
+
const { parsed, providerName, provider, adapterName } = args;
|
|
423
|
+
let credentialIdentity: string | undefined;
|
|
424
|
+
let credentialDurableIdentity: string | undefined;
|
|
425
|
+
const durableSalt = thoughtSignatureReplaySalt();
|
|
426
|
+
if (provider.authMode === "oauth") {
|
|
427
|
+
credentialIdentity = reasoningReplayOAuthCredentialIdentity(
|
|
428
|
+
args.oauthCredentialSnapshot,
|
|
429
|
+
provider.headers,
|
|
430
|
+
);
|
|
431
|
+
// The persisted account-slot id survives token refresh and restarts; the rotating
|
|
432
|
+
// generation deliberately does NOT participate (#1926 design: rotation-safe).
|
|
433
|
+
credentialDurableIdentity = durableReplayCredentialIdentity(
|
|
434
|
+
"oauth",
|
|
435
|
+
args.oauthCredentialSnapshot?.accountId,
|
|
436
|
+
provider.headers,
|
|
437
|
+
durableSalt,
|
|
438
|
+
);
|
|
439
|
+
} else if (provider.authMode === "forward") {
|
|
440
|
+
const poolContext = args.codexAuthContext?.kind === "pool"
|
|
441
|
+
|| args.codexAuthContext?.kind === "main-pool"
|
|
442
|
+
? args.codexAuthContext
|
|
443
|
+
: undefined;
|
|
444
|
+
credentialIdentity = reasoningReplayCodexCredentialIdentity({
|
|
445
|
+
authorization: poolContext
|
|
446
|
+
? `Bearer ${poolContext.accessToken}`
|
|
447
|
+
: args.forwardHeaders?.get("authorization"),
|
|
448
|
+
chatgptAccountId: poolContext?.chatgptAccountId
|
|
449
|
+
?? args.forwardHeaders?.get("chatgpt-account-id"),
|
|
450
|
+
accountId: poolContext?.accountId,
|
|
451
|
+
credentialGeneration: poolContext?.kind === "pool"
|
|
452
|
+
? poolContext.generation
|
|
453
|
+
: undefined,
|
|
454
|
+
writerGeneration: poolContext?.writerGeneration,
|
|
455
|
+
headers: provider.headers,
|
|
456
|
+
});
|
|
457
|
+
// Durable identity requires a STABLE, TRUSTED account handle. Pool context comes from
|
|
458
|
+
// our own account store; a client-supplied chatgpt-account-id header is attacker
|
|
459
|
+
// -influenceable bucket selection and a bearer alone is rotating material — both are
|
|
460
|
+
// refused, so direct-forward turns get no durable scope (fail closed; the in-process
|
|
461
|
+
// cache still covers same-process replay).
|
|
462
|
+
const codexDurableHandle = poolContext?.accountId
|
|
463
|
+
?? poolContext?.chatgptAccountId
|
|
464
|
+
?? undefined;
|
|
465
|
+
credentialDurableIdentity = durableReplayCredentialIdentity(
|
|
466
|
+
"codex",
|
|
467
|
+
codexDurableHandle ?? undefined,
|
|
468
|
+
provider.headers,
|
|
469
|
+
durableSalt,
|
|
470
|
+
);
|
|
471
|
+
} else if (provider.authMode !== "local") {
|
|
472
|
+
credentialIdentity = reasoningReplayKeyCredentialIdentity(provider);
|
|
473
|
+
credentialDurableIdentity = durableReplayCredentialIdentity(
|
|
474
|
+
"key",
|
|
475
|
+
nonEmptyProviderApiKey(provider),
|
|
476
|
+
provider.headers,
|
|
477
|
+
durableSalt,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const providerDestinationIdentity = reasoningReplayDestinationIdentity(provider.baseUrl);
|
|
481
|
+
const replayIdentity: OcxReasoningReplayIdentity | undefined = credentialIdentity && providerDestinationIdentity
|
|
482
|
+
? {
|
|
483
|
+
providerName,
|
|
484
|
+
providerDestinationIdentity,
|
|
485
|
+
providerDestinationDurableIdentity: durableReplayDestinationIdentity(provider.baseUrl),
|
|
486
|
+
adapterName,
|
|
487
|
+
modelId: parsed.modelId,
|
|
488
|
+
credentialIdentity,
|
|
489
|
+
...(credentialDurableIdentity ? { credentialDurableIdentity } : {}),
|
|
490
|
+
}
|
|
491
|
+
: undefined;
|
|
492
|
+
const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider);
|
|
493
|
+
const continuationOwner = providerContinuationOwnerFromReplayIdentity(
|
|
494
|
+
replayIdentity && continuationDestinationIdentity
|
|
495
|
+
? { ...replayIdentity, providerDestinationIdentity: continuationDestinationIdentity }
|
|
496
|
+
: undefined,
|
|
497
|
+
);
|
|
498
|
+
if (adapterName === "cursor") {
|
|
499
|
+
// The final route owner is authoritative for Cursor and supersedes the account-derived
|
|
500
|
+
// seed assigned before route binding. A Cursor conversation must be scoped to the exact
|
|
501
|
+
// provider/destination/adapter/model/credential that serves it.
|
|
502
|
+
if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner);
|
|
503
|
+
else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) {
|
|
504
|
+
// Prevent the adapter's token-only fallback from recreating a provider-private id after the
|
|
505
|
+
// route owner failed closed. The sentinel is per parsed request and contains no credential.
|
|
506
|
+
parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
bindReasoningReplayScope(
|
|
510
|
+
parsed._reasoningReplayScope,
|
|
511
|
+
replayIdentity,
|
|
512
|
+
);
|
|
513
|
+
// Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal
|
|
514
|
+
// after the first mismatch, but it cannot make history minted by the prior route decodable.
|
|
515
|
+
if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
|
|
516
|
+
parsed._stripReasoningEncryptedContent = true;
|
|
517
|
+
}
|
|
518
|
+
bindProviderContinuationForRoute(parsed, continuationOwner);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function adapterResponseReachedServingTerminal(
|
|
522
|
+
events: readonly AdapterEvent[],
|
|
523
|
+
response: Readonly<Record<string, unknown>>,
|
|
524
|
+
): boolean {
|
|
525
|
+
return (response.status === "completed" || response.status === "incomplete")
|
|
526
|
+
&& events.some(event => event.type === "done" || event.type === "incomplete");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const OPAQUE_RESPONSES_INPUT_TYPES = new Set([
|
|
530
|
+
"reasoning",
|
|
531
|
+
"compaction",
|
|
532
|
+
"compaction_summary",
|
|
533
|
+
"context_compaction",
|
|
534
|
+
]);
|
|
535
|
+
|
|
536
|
+
function outboundResponsesBodyCarriesOpaqueBlob(bodyText: string | undefined): boolean {
|
|
537
|
+
if (!bodyText) return false;
|
|
538
|
+
try {
|
|
539
|
+
const body = JSON.parse(bodyText) as unknown;
|
|
540
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
541
|
+
const input = (body as { input?: unknown }).input;
|
|
542
|
+
if (!Array.isArray(input)) return false;
|
|
543
|
+
return input.some(item => {
|
|
544
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
|
|
545
|
+
const candidate = item as { type?: unknown; encrypted_content?: unknown };
|
|
546
|
+
return typeof candidate.type === "string"
|
|
547
|
+
&& OPAQUE_RESPONSES_INPUT_TYPES.has(candidate.type)
|
|
548
|
+
&& typeof candidate.encrypted_content === "string"
|
|
549
|
+
&& candidate.encrypted_content.length > 0;
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
557
|
+
try {
|
|
558
|
+
const payload = JSON.parse(bodyText) as unknown;
|
|
559
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
560
|
+
const record = payload as { code?: unknown; error?: unknown };
|
|
561
|
+
|
|
562
|
+
if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
|
|
563
|
+
const error = record.error as { type?: unknown; code?: unknown; message?: unknown };
|
|
564
|
+
if (error.type === "invalid_request_error") {
|
|
565
|
+
if (error.code === "invalid_encrypted_content") return true;
|
|
566
|
+
if (
|
|
567
|
+
(error.code === null || error.code === undefined)
|
|
568
|
+
&& typeof error.message === "string"
|
|
569
|
+
&& error.message.startsWith("The encrypted content ")
|
|
570
|
+
&& error.message.endsWith(
|
|
571
|
+
" could not be verified. Reason: Encrypted content could not be decrypted or parsed.",
|
|
572
|
+
)
|
|
573
|
+
) return true;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (record.code !== "invalid-argument" || typeof record.error !== "string") return false;
|
|
578
|
+
return record.error.startsWith("Could not decode the compaction blob")
|
|
579
|
+
|| record.error.startsWith("Could not decrypt the provided encrypted_content");
|
|
580
|
+
} catch {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Whether an upstream Responses 4xx authoritatively rejected opaque replay state.
|
|
587
|
+
*
|
|
588
|
+
* The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or
|
|
589
|
+
* compaction blob that the adapter already lowered, in which case a replay would be byte-identical.
|
|
590
|
+
* OpenAI usually exposes a dedicated nested code; ChatGPT also emits one exact code-less
|
|
591
|
+
* unverifiable-ciphertext message. xAI's code is generic, so its two concrete decoder error
|
|
592
|
+
* identities are also required. Unrelated error prose must never gain a hidden resend.
|
|
593
|
+
*/
|
|
594
|
+
export function shouldAttemptOpaqueBlobRecovery(args: {
|
|
595
|
+
status: number;
|
|
596
|
+
adapterName: string;
|
|
597
|
+
outboundBody?: string;
|
|
598
|
+
errorBody: string;
|
|
599
|
+
alreadyAttempted: boolean;
|
|
600
|
+
}): boolean {
|
|
601
|
+
return args.status >= 400
|
|
602
|
+
&& args.status < 500
|
|
603
|
+
&& args.adapterName === "openai-responses"
|
|
604
|
+
&& !args.alreadyAttempted
|
|
605
|
+
&& outboundResponsesBodyCarriesOpaqueBlob(args.outboundBody)
|
|
606
|
+
&& isSelfIdentifiedOpaqueBlobRejection(args.errorBody);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function opaqueBlobRejectionBodyForRecovery(
|
|
610
|
+
response: Response,
|
|
611
|
+
outboundBody: string | undefined,
|
|
612
|
+
adapterName: string,
|
|
613
|
+
alreadyAttempted: boolean,
|
|
614
|
+
signal: AbortSignal,
|
|
615
|
+
): Promise<string | undefined> {
|
|
616
|
+
if (
|
|
617
|
+
response.status < 400
|
|
618
|
+
|| response.status >= 500
|
|
619
|
+
|| adapterName !== "openai-responses"
|
|
620
|
+
|| alreadyAttempted
|
|
621
|
+
|| !outboundResponsesBodyCarriesOpaqueBlob(outboundBody)
|
|
622
|
+
) return undefined;
|
|
623
|
+
try {
|
|
624
|
+
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
625
|
+
return body.displaySafe && !body.truncated ? body.text : undefined;
|
|
626
|
+
} catch {
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void {
|
|
632
|
+
parsed._stripReasoningEncryptedContent = true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
type OpaqueBlobRecoveryGuard = { attempted: boolean };
|
|
636
|
+
|
|
637
|
+
type OpaqueBlobRecoveryResult =
|
|
638
|
+
| { kind: "skipped" }
|
|
639
|
+
| { kind: "recovered"; response: Response }
|
|
640
|
+
| { kind: "failed"; response: Response };
|
|
641
|
+
|
|
642
|
+
async function attemptOpaqueBlobRecovery(
|
|
643
|
+
args: {
|
|
644
|
+
response: Response;
|
|
645
|
+
outboundBody?: string;
|
|
646
|
+
adapterName: string;
|
|
647
|
+
parsed: OcxParsedRequest;
|
|
648
|
+
guard: OpaqueBlobRecoveryGuard;
|
|
649
|
+
signal: AbortSignal;
|
|
650
|
+
},
|
|
651
|
+
rebuild: (kind: AttemptRecoveryKind) => Promise<Response | { failed: Response }>,
|
|
652
|
+
): Promise<OpaqueBlobRecoveryResult> {
|
|
653
|
+
const errorBody = await opaqueBlobRejectionBodyForRecovery(
|
|
654
|
+
args.response,
|
|
655
|
+
args.outboundBody,
|
|
656
|
+
args.adapterName,
|
|
657
|
+
args.guard.attempted,
|
|
658
|
+
args.signal,
|
|
659
|
+
);
|
|
660
|
+
if (errorBody === undefined || !shouldAttemptOpaqueBlobRecovery({
|
|
661
|
+
status: args.response.status,
|
|
662
|
+
adapterName: args.adapterName,
|
|
663
|
+
outboundBody: args.outboundBody,
|
|
664
|
+
errorBody,
|
|
665
|
+
alreadyAttempted: args.guard.attempted,
|
|
666
|
+
})) {
|
|
667
|
+
return { kind: "skipped" };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
args.guard.attempted = true;
|
|
671
|
+
prepareOpaqueBlobRecovery(args.parsed);
|
|
672
|
+
try { void args.response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
673
|
+
const result = await rebuild("opaque-blob-rejection");
|
|
674
|
+
return "failed" in result
|
|
675
|
+
? { kind: "failed", response: result.failed }
|
|
676
|
+
: { kind: "recovered", response: result };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined {
|
|
680
|
+
return typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0
|
|
681
|
+
? provider.apiKey
|
|
682
|
+
: undefined;
|
|
683
|
+
}
|
|
199
684
|
|
|
685
|
+
function isFixedCodexAccount(authCtx: CodexAuthContext): boolean {
|
|
686
|
+
return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
687
|
+
&& authCtx.fixedAccount === true;
|
|
688
|
+
}
|
|
200
689
|
|
|
201
690
|
export function usesCodexForwardPoolAuth(
|
|
202
691
|
authCtx: CodexAuthContext,
|
|
@@ -206,6 +695,30 @@ export function usesCodexForwardPoolAuth(
|
|
|
206
695
|
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
207
696
|
}
|
|
208
697
|
|
|
698
|
+
export function preAuthUpstreamHostCircuitKey(
|
|
699
|
+
route: Pick<RouteResult, "provider" | "providerName" | "codexAccountMode" | "codexAccountId">,
|
|
700
|
+
config: OcxConfig,
|
|
701
|
+
options: { requireResponsesAdapter?: boolean } = {},
|
|
702
|
+
): string | null {
|
|
703
|
+
if (
|
|
704
|
+
normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) === 0
|
|
705
|
+
|| route.codexAccountMode !== "pool"
|
|
706
|
+
|| route.codexAccountId !== undefined
|
|
707
|
+
|| route.provider.authMode !== "forward"
|
|
708
|
+
|| (options.requireResponsesAdapter !== false && route.provider.adapter !== "openai-responses")
|
|
709
|
+
) return null;
|
|
710
|
+
return upstreamHostHealthKey(route.providerName, safeOriginLabel(route.provider.baseUrl ?? ""));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function upstreamHostCircuitOpenResponse(retryAfterSeconds: number): Response {
|
|
714
|
+
return formatErrorResponse(
|
|
715
|
+
503,
|
|
716
|
+
"upstream_host_circuit_open",
|
|
717
|
+
"Provider host is temporarily unavailable",
|
|
718
|
+
{ retryAfter: String(retryAfterSeconds) },
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
209
722
|
function normalizeCodexUnsupportedModelDetail(value: string): string {
|
|
210
723
|
return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
|
|
211
724
|
}
|
|
@@ -246,8 +759,8 @@ async function shouldRetryCodexPoolAccountModel400(
|
|
|
246
759
|
}
|
|
247
760
|
|
|
248
761
|
/** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
|
|
249
|
-
function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
|
|
250
|
-
return response.status ===
|
|
762
|
+
export function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
|
|
763
|
+
return response.status === 402 || response.status === 429;
|
|
251
764
|
}
|
|
252
765
|
|
|
253
766
|
interface CodexPoolAccountRetryArgs {
|
|
@@ -264,7 +777,9 @@ interface CodexPoolAccountRetryArgs {
|
|
|
264
777
|
// needs the inbound scope or the retry could land on a different wire than the
|
|
265
778
|
// first attempt.
|
|
266
779
|
inboundWire?: InboundWire;
|
|
780
|
+
codexWsRuntimeIdentity?: BunRuntimeGateInput;
|
|
267
781
|
translatorBudget: TranslatorBudget;
|
|
782
|
+
turnAdmissionLease?: AdmissionLease;
|
|
268
783
|
};
|
|
269
784
|
firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
270
785
|
firstResponse: Response;
|
|
@@ -273,6 +788,11 @@ interface CodexPoolAccountRetryArgs {
|
|
|
273
788
|
connectMs: number;
|
|
274
789
|
passthroughEstimate?: number;
|
|
275
790
|
stream: boolean;
|
|
791
|
+
onResponse?: (
|
|
792
|
+
response: Response,
|
|
793
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>,
|
|
794
|
+
request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>,
|
|
795
|
+
) => void;
|
|
276
796
|
}
|
|
277
797
|
|
|
278
798
|
type CodexPoolAccountRetryResult =
|
|
@@ -290,6 +810,53 @@ type CodexPoolAccountRetryResult =
|
|
|
290
810
|
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
291
811
|
};
|
|
292
812
|
|
|
813
|
+
const CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS: ReadonlyMap<string, string> = new Map([
|
|
814
|
+
// The authenticated catalog currently advertises Daybreak Blue, while successful responses
|
|
815
|
+
// identify the serving model as gpt-5.6-sol. Sending the selector itself is shard-dependent:
|
|
816
|
+
// live traffic can receive the exact unsupported-model 400 repeatedly from the same entitled
|
|
817
|
+
// account. Keep Daybreak as the admission/catalog identity, but use the stable serving id on
|
|
818
|
+
// the credential-bearing wire after entitlement selection has completed.
|
|
819
|
+
["gpt-daybreak-blue-latest", "gpt-5.6-sol"],
|
|
820
|
+
]);
|
|
821
|
+
|
|
822
|
+
export function codexAccountGatedCanonicalWireModel(modelId: string): string | undefined {
|
|
823
|
+
const exact = CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS.get(modelId);
|
|
824
|
+
if (exact) return exact;
|
|
825
|
+
for (const [selector, wireModel] of CODEX_ACCOUNT_GATED_CANONICAL_WIRE_MODELS) {
|
|
826
|
+
if (slugsEquivalent(modelId, selector)) return wireModel;
|
|
827
|
+
}
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult): void {
|
|
832
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
|
|
833
|
+
const wireModel = codexAccountGatedCanonicalWireModel(route.modelId);
|
|
834
|
+
if (!wireModel) return;
|
|
835
|
+
|
|
836
|
+
parsed.modelId = wireModel;
|
|
837
|
+
if (!parsed._rawBody || typeof parsed._rawBody !== "object") return;
|
|
838
|
+
const raw = parsed._rawBody as Record<string, unknown>;
|
|
839
|
+
raw.model = wireModel;
|
|
840
|
+
// Daybreak's authenticated catalog does not advertise retention support, and the upstream
|
|
841
|
+
// rejects this optional Codex hint before model execution. Removing it preserves request
|
|
842
|
+
// semantics while avoiding an otherwise terminal pre-stream 400.
|
|
843
|
+
delete raw.prompt_cache_retention;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Workspace-denial evidence for a 403, read from the upstream body.
|
|
848
|
+
*
|
|
849
|
+
* #1789: a valid K12 credential gets 403 `codex_workspace_access_denied` on a routed prompt.
|
|
850
|
+
* Without this the account is quarantined for reauthentication, which cannot fix a workspace
|
|
851
|
+
* grant and loops forever. Fails closed: an unreadable body keeps the historical handling.
|
|
852
|
+
*/
|
|
853
|
+
async function codexDenialOutcomeMeta(response: Response): Promise<{ denial?: "workspace" | "entitlement" }> {
|
|
854
|
+
if (response.status !== 403) return {};
|
|
855
|
+
const { classifyCodexPreStreamRejection } = await import("../../codex/quota-rejection");
|
|
856
|
+
const rejection = await classifyCodexPreStreamRejection(response);
|
|
857
|
+
return rejection.denial ? { denial: rejection.denial } : {};
|
|
858
|
+
}
|
|
859
|
+
|
|
293
860
|
function codexQuotaOutcomeMeta(response: Response): {
|
|
294
861
|
retryAfter: string | null;
|
|
295
862
|
resetAt: string[];
|
|
@@ -328,25 +895,43 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
328
895
|
} = args;
|
|
329
896
|
const inboundWire = options.inboundWire ?? "responses";
|
|
330
897
|
let retryAuthCtx: CodexAuthContext | undefined;
|
|
898
|
+
if (outcomeStatus === 400 && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)) {
|
|
899
|
+
invalidateCodexModelEntitlementsForAccount(firstAuthCtx.accountId);
|
|
900
|
+
const refreshed = await resolveCodexModelEntitlements(config);
|
|
901
|
+
if (entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(firstAuthCtx.accountId)) {
|
|
902
|
+
// The authenticated roster still grants this exact model. Retry on the same account:
|
|
903
|
+
// upstream shards can briefly disagree during a gated-model rollout, but a pre-stream 400
|
|
904
|
+
// proves no output was committed and keeps this replay bounded.
|
|
905
|
+
retryAuthCtx = firstAuthCtx;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Exact account selectors may retry the same confirmed account above, but must never resolve
|
|
909
|
+
// an alternate. Quota failures and a refreshed entitlement miss remain terminal.
|
|
910
|
+
if (!retryAuthCtx && firstAuthCtx.fixedAccount) return { kind: "no-alternate" };
|
|
331
911
|
try {
|
|
332
|
-
retryAuthCtx
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
912
|
+
retryAuthCtx ??= await resolveCodexAuthContext(
|
|
913
|
+
req.headers,
|
|
914
|
+
config,
|
|
915
|
+
"pool",
|
|
916
|
+
{
|
|
917
|
+
excludeAccountId: firstAuthCtx.accountId,
|
|
918
|
+
modelId: route.modelId,
|
|
919
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
920
|
+
},
|
|
921
|
+
);
|
|
338
922
|
} catch (error) {
|
|
339
923
|
if (
|
|
340
924
|
!(error instanceof CodexPoolAuthenticationError)
|
|
341
925
|
&& !(error instanceof CodexAuthContextError)
|
|
342
926
|
&& !(error instanceof CodexAccountCooldownError)
|
|
927
|
+
&& !(error instanceof CodexMainProfileDrainingError)
|
|
343
928
|
) throw error;
|
|
344
929
|
}
|
|
345
930
|
if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
|
|
346
931
|
return { kind: "no-alternate" };
|
|
347
932
|
}
|
|
348
933
|
|
|
349
|
-
const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
|
|
934
|
+
const quotaMeta = { ...codexQuotaOutcomeMeta(firstResponse), ...(await codexDenialOutcomeMeta(firstResponse)) };
|
|
350
935
|
if (outcomeStatus === 429 || outcomeStatus === 402) {
|
|
351
936
|
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
352
937
|
applyAccountQuotaFromUpstreamHeaders(
|
|
@@ -355,10 +940,14 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
355
940
|
firstAuthCtx.writerGeneration,
|
|
356
941
|
);
|
|
357
942
|
}
|
|
358
|
-
|
|
943
|
+
const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown(
|
|
944
|
+
firstResponse,
|
|
945
|
+
options.deferCodexResetDerivedCooldown,
|
|
946
|
+
);
|
|
947
|
+
const recordFirstOutcome = (): void => {
|
|
359
948
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
360
949
|
...quotaMeta,
|
|
361
|
-
threadId:
|
|
950
|
+
threadId: firstAuthCtx.affinityKey,
|
|
362
951
|
modelId: route.modelId,
|
|
363
952
|
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
364
953
|
probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
|
|
@@ -366,8 +955,10 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
366
955
|
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
367
956
|
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
368
957
|
});
|
|
369
|
-
}
|
|
370
|
-
|
|
958
|
+
};
|
|
959
|
+
// Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
|
|
960
|
+
// ordinary requests must block the first account before the alternate send.
|
|
961
|
+
if (!deferFirstOutcome) recordFirstOutcome();
|
|
371
962
|
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
372
963
|
const retryProvider = applyCodexAuthContextToProvider(
|
|
373
964
|
stripCodexRuntimeProviderFields(route.provider),
|
|
@@ -378,11 +969,20 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
378
969
|
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider, inboundWire),
|
|
379
970
|
config.cacheRetention,
|
|
380
971
|
);
|
|
972
|
+
bindRouteReasoningReplayScope({
|
|
973
|
+
parsed,
|
|
974
|
+
providerName: route.providerName,
|
|
975
|
+
provider: retryProvider,
|
|
976
|
+
adapterName: retryAdapter.name,
|
|
977
|
+
codexAuthContext: retryAuthCtx,
|
|
978
|
+
forwardHeaders: retryHeaders,
|
|
979
|
+
});
|
|
381
980
|
const request = await retryAdapter.buildRequest(parsed, {
|
|
382
981
|
headers: retryHeaders,
|
|
383
982
|
translatorBudget: options.translatorBudget,
|
|
384
983
|
});
|
|
385
984
|
recordAdapterReasoning(logCtx, request);
|
|
985
|
+
recordAdapterTier(logCtx, request);
|
|
386
986
|
|
|
387
987
|
await firstResponse.body?.cancel().catch(() => undefined);
|
|
388
988
|
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
@@ -392,34 +992,86 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
392
992
|
retryAuthCtx.accountId,
|
|
393
993
|
config,
|
|
394
994
|
);
|
|
995
|
+
logCtx.accountLogLabel = codexAuthContextLogLabel(retryAuthCtx, config);
|
|
996
|
+
sealRequestAttemptIdentity(
|
|
997
|
+
logCtx.activeAttempt,
|
|
998
|
+
logCtx.provider,
|
|
999
|
+
retryAdapter.name,
|
|
1000
|
+
logCtx.accountLogLabel,
|
|
1001
|
+
);
|
|
395
1002
|
|
|
396
|
-
|
|
1003
|
+
const retrySameConfirmedAccount = outcomeStatus === 400
|
|
1004
|
+
&& ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId)
|
|
1005
|
+
&& retryAuthCtx.accountId === firstAuthCtx.accountId;
|
|
1006
|
+
// Live Daybreak traffic has produced long runs of unsupported-model 400s from different
|
|
1007
|
+
// upstream shards even while the authenticated roster continues to grant the model. Permit
|
|
1008
|
+
// seven additional same-account sends (eight total including the original), re-checking the
|
|
1009
|
+
// exact allow-listed body and fresh entitlement before every later send. Alternate-account and
|
|
1010
|
+
// quota recovery retain their historical one-send bound.
|
|
1011
|
+
const maxRetrySends = retrySameConfirmedAccount ? 7 : 1;
|
|
1012
|
+
let retrySendCount = 0;
|
|
1013
|
+
let upstreamResponse: Response;
|
|
397
1014
|
try {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
1015
|
+
while (true) {
|
|
1016
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
1017
|
+
upstreamResponse = await fetchWithHeaderTimeout(
|
|
1018
|
+
request.url,
|
|
1019
|
+
{
|
|
1020
|
+
method: request.method,
|
|
1021
|
+
headers: request.headers,
|
|
1022
|
+
body: request.body,
|
|
1023
|
+
},
|
|
1024
|
+
upstream.signal,
|
|
1025
|
+
connectMs,
|
|
1026
|
+
stream,
|
|
1027
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
1028
|
+
providerName: route.providerName,
|
|
1029
|
+
modelId: route.modelId,
|
|
1030
|
+
}),
|
|
1031
|
+
// Credential-bearing forward send: never follow a redirect into a
|
|
1032
|
+
// dead-host rejection after the credential was seen (#914).
|
|
1033
|
+
route.provider.authMode === "forward",
|
|
1034
|
+
);
|
|
1035
|
+
retrySendCount += 1;
|
|
1036
|
+
args.onResponse?.(upstreamResponse, retryAuthCtx, request);
|
|
1037
|
+
if (!retrySameConfirmedAccount || retrySendCount >= maxRetrySends) break;
|
|
1038
|
+
if (!await shouldRetryCodexPoolAccountModel400(
|
|
1039
|
+
upstreamResponse,
|
|
1040
|
+
route.modelId,
|
|
1041
|
+
options.abortSignal,
|
|
1042
|
+
)) break;
|
|
1043
|
+
invalidateCodexModelEntitlementsForAccount(retryAuthCtx.accountId);
|
|
1044
|
+
const refreshed = await resolveCodexModelEntitlements(config);
|
|
1045
|
+
if (!entitledCodexAccountIdsForModel(refreshed, route.modelId)?.has(retryAuthCtx.accountId)) break;
|
|
1046
|
+
await upstreamResponse.body?.cancel().catch(() => undefined);
|
|
1047
|
+
}
|
|
417
1048
|
} catch (error) {
|
|
418
1049
|
// Attribute the transport failure to the alternate account (already selected).
|
|
419
1050
|
return { kind: "transport", error, authCtx: retryAuthCtx };
|
|
420
1051
|
} finally {
|
|
421
1052
|
request.releaseBodyObservation?.();
|
|
422
1053
|
}
|
|
1054
|
+
// A real HTTP response proves the host was reached (#914).
|
|
1055
|
+
const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
|
|
1056
|
+
if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
|
|
1057
|
+
resetUpstreamHostHealth(retryHostKey, null);
|
|
1058
|
+
} else {
|
|
1059
|
+
resetUpstreamHostHealth(retryHostKey);
|
|
1060
|
+
}
|
|
1061
|
+
if (deferFirstOutcome && upstreamResponse.ok) {
|
|
1062
|
+
// Deferral keeps the first account eligible for a later combo model while an
|
|
1063
|
+
// alternate attempt is still fallible. Commit its quota outcome only once the
|
|
1064
|
+
// alternate account returns a successful HTTP response; otherwise the combo may
|
|
1065
|
+
// still need the first account for its next target.
|
|
1066
|
+
recordFirstOutcome();
|
|
1067
|
+
}
|
|
1068
|
+
return {
|
|
1069
|
+
kind: "retried",
|
|
1070
|
+
authCtx: retryAuthCtx,
|
|
1071
|
+
request,
|
|
1072
|
+
upstreamResponse,
|
|
1073
|
+
selectedForwardHeaders: retryHeaders,
|
|
1074
|
+
};
|
|
423
1075
|
}
|
|
424
1076
|
|
|
425
1077
|
|
|
@@ -430,7 +1082,6 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
430
1082
|
provider: OcxProviderConfig,
|
|
431
1083
|
modelId?: string,
|
|
432
1084
|
logCtx?: RequestLogContext,
|
|
433
|
-
threadId?: string | null,
|
|
434
1085
|
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
435
1086
|
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
436
1087
|
return (status, httpStatusOverride) => {
|
|
@@ -439,7 +1090,8 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
439
1090
|
// request. Don't penalize account health; record success to clear any
|
|
440
1091
|
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
441
1092
|
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
442
|
-
threadId,
|
|
1093
|
+
threadId: authCtx.affinityKey,
|
|
1094
|
+
fixedAccount: authCtx.fixedAccount,
|
|
443
1095
|
modelId,
|
|
444
1096
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
445
1097
|
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
@@ -460,7 +1112,8 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
460
1112
|
? 200
|
|
461
1113
|
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
462
1114
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
463
|
-
threadId,
|
|
1115
|
+
threadId: authCtx.affinityKey,
|
|
1116
|
+
fixedAccount: authCtx.fixedAccount,
|
|
464
1117
|
modelId,
|
|
465
1118
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
466
1119
|
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
@@ -515,6 +1168,17 @@ export interface ConsumedComboFailure {
|
|
|
515
1168
|
|
|
516
1169
|
export interface HandleResponsesOptions {
|
|
517
1170
|
turnAdmissionLease?: AdmissionLease;
|
|
1171
|
+
/**
|
|
1172
|
+
* How the caller proved data-plane admission (#1686).
|
|
1173
|
+
*
|
|
1174
|
+
* A bearer-presented admission secret is one of OUR OWN secrets, so a Direct turn must
|
|
1175
|
+
* SUBSTITUTE the stored main credential rather than forward it. Without this fact at the
|
|
1176
|
+
* decision point, Direct cannot tell an admission bearer from the user own ChatGPT bearer,
|
|
1177
|
+
* which is why it refused the whole env_key flow instead of serving it.
|
|
1178
|
+
*/
|
|
1179
|
+
admission?: DataPlaneAdmission;
|
|
1180
|
+
/** Called at most once after the complete client body is read and accepted for dispatch. */
|
|
1181
|
+
onRequestBodyRead?: () => void;
|
|
518
1182
|
forceEmptyResponseId?: boolean;
|
|
519
1183
|
abortSignal?: AbortSignal;
|
|
520
1184
|
/** One-shot TTFT callback: first non-empty model output observed (WP4). */
|
|
@@ -524,6 +1188,10 @@ export interface HandleResponsesOptions {
|
|
|
524
1188
|
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
|
|
525
1189
|
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
526
1190
|
onNativePassthroughCancel?: () => void;
|
|
1191
|
+
/** Internal deterministic clock/timer seam for provider terminal repair. */
|
|
1192
|
+
responsesTerminalRepairScheduler?: ResponsesTerminalRepairScheduler;
|
|
1193
|
+
/** Internal deterministic runtime-identity seam for Codex upstream WS selection tests. */
|
|
1194
|
+
codexWsRuntimeIdentity?: BunRuntimeGateInput;
|
|
527
1195
|
/**
|
|
528
1196
|
* When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
|
|
529
1197
|
* (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
|
|
@@ -537,18 +1205,45 @@ export interface HandleResponsesOptions {
|
|
|
537
1205
|
* it. Omitted means a genuine Responses inbound.
|
|
538
1206
|
*/
|
|
539
1207
|
inboundWire?: InboundWire;
|
|
1208
|
+
/** Internal transport identity for route-scoped upstream compatibility policy. */
|
|
1209
|
+
inboundTransport?: "websocket";
|
|
1210
|
+
/**
|
|
1211
|
+
* Claude replay may add native-main auth so OpenAI sidecars remain available.
|
|
1212
|
+
* Strip only that internal credential when the final route is a noncanonical
|
|
1213
|
+
* forward destination; final routing can differ from Claude's preflight route.
|
|
1214
|
+
*/
|
|
1215
|
+
stripClaudeMainAuthForNoncanonicalForward?: boolean;
|
|
540
1216
|
/** Internal recursion guard; callers outside this module must not set it. */
|
|
541
1217
|
comboAttempt?: boolean;
|
|
1218
|
+
/** Internal combo handoff for one parent-validated continuation snapshot. */
|
|
1219
|
+
comboReplaySnapshot?: {
|
|
1220
|
+
sourceBody: unknown;
|
|
1221
|
+
previousResponseInputExpanded: boolean;
|
|
1222
|
+
providerContinuation: OcxProviderContinuationState | undefined;
|
|
1223
|
+
};
|
|
542
1224
|
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
|
|
543
1225
|
deferCodexResetDerivedCooldown?: boolean;
|
|
544
1226
|
/** 030-owned handoff when a child consumed the original failure under bounds. */
|
|
545
1227
|
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
|
|
546
1228
|
/** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */
|
|
547
1229
|
translatorBudget?: TranslatorBudget;
|
|
1230
|
+
/**
|
|
1231
|
+
* Terminal vision-describe marker (roadmap 180): true when the inbound
|
|
1232
|
+
* request IS the vision sidecar's own loopback describe call. The plan site
|
|
1233
|
+
* then STRIPS images instead of planning another describe — a depth cap of 1
|
|
1234
|
+
* that holds under predicate drift and combo re-resolution. The Chat surface
|
|
1235
|
+
* detects the raw `x-opencodex-vision-describe` header before its bridge
|
|
1236
|
+
* rebuilds headers and carries the fact through this flag.
|
|
1237
|
+
*/
|
|
1238
|
+
visionDescribeTerminal?: boolean;
|
|
548
1239
|
}
|
|
549
1240
|
|
|
550
1241
|
|
|
551
1242
|
|
|
1243
|
+
/**
|
|
1244
|
+
* Build the 499 JSON error the proxy returns when the client disconnects before the
|
|
1245
|
+
* response completes (`client_cancelled`).
|
|
1246
|
+
*/
|
|
552
1247
|
export function clientCancelledResponse(): Response {
|
|
553
1248
|
return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
|
|
554
1249
|
}
|
|
@@ -685,6 +1380,40 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
|
685
1380
|
const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
|
|
686
1381
|
"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.";
|
|
687
1382
|
|
|
1383
|
+
// Whole-body policy for non-streaming upstream JSON responses (see the application/json
|
|
1384
|
+
// branch of the passthrough return path). 32 MiB matches the continuation snapshot read
|
|
1385
|
+
// bound and is far above any legitimate non-streaming completion, including base64 image
|
|
1386
|
+
// payloads. The stall deadlines only govern the body transfer — generation time before
|
|
1387
|
+
// the response headers is untouched. Generation after early/chunked headers but before
|
|
1388
|
+
// the first body byte previously used the 30-second inactivity deadline; this call site
|
|
1389
|
+
// gives it the full body deadline instead.
|
|
1390
|
+
const MAX_UPSTREAM_JSON_BODY_BYTES = 32 * 1024 * 1024;
|
|
1391
|
+
const UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS = 180_000;
|
|
1392
|
+
const UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS = 30_000;
|
|
1393
|
+
const MAX_FAST_WIRE_CAPABILITY_WARNINGS = 256;
|
|
1394
|
+
const warnedFastWireCapabilityGaps = new Set<string>();
|
|
1395
|
+
|
|
1396
|
+
function warnFastWireCapabilityGap(providerName: string, modelId: string): void {
|
|
1397
|
+
const safeProvider = sanitizeLogMetadataString(providerName) ?? "unknown";
|
|
1398
|
+
const safeModel = sanitizeLogMetadataString(modelId) ?? "unknown";
|
|
1399
|
+
const key = `${safeProvider}\0${safeModel}`;
|
|
1400
|
+
if (warnedFastWireCapabilityGaps.has(key)) return;
|
|
1401
|
+
if (warnedFastWireCapabilityGaps.size >= MAX_FAST_WIRE_CAPABILITY_WARNINGS) {
|
|
1402
|
+
const oldest = warnedFastWireCapabilityGaps.values().next().value;
|
|
1403
|
+
if (oldest !== undefined) warnedFastWireCapabilityGaps.delete(oldest);
|
|
1404
|
+
}
|
|
1405
|
+
warnedFastWireCapabilityGaps.add(key);
|
|
1406
|
+
console.warn(
|
|
1407
|
+
`[opencodex] Fast policy for ${safeProvider}/${safeModel} has service-tier capability but no Fast wire; preserving only caller-permitted tier behavior`,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
export const UPSTREAM_JSON_BODY_READ_OPTIONS = {
|
|
1411
|
+
maxBytes: MAX_UPSTREAM_JSON_BODY_BYTES,
|
|
1412
|
+
totalTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS,
|
|
1413
|
+
inactivityTimeoutMs: UPSTREAM_JSON_BODY_INACTIVITY_TIMEOUT_MS,
|
|
1414
|
+
firstByteTimeoutMs: UPSTREAM_JSON_BODY_TOTAL_TIMEOUT_MS,
|
|
1415
|
+
};
|
|
1416
|
+
|
|
688
1417
|
function unreadableEncryptedAgentTaskResponse(): Response {
|
|
689
1418
|
return new Response(
|
|
690
1419
|
JSON.stringify({
|
|
@@ -699,7 +1428,7 @@ function unreadableEncryptedAgentTaskResponse(): Response {
|
|
|
699
1428
|
}
|
|
700
1429
|
|
|
701
1430
|
type ResponsesAuthResolution =
|
|
702
|
-
| { ok: true; authCtx: CodexAuthContext; headers: Headers }
|
|
1431
|
+
| { ok: true; authCtx: CodexAuthContext; headers: Headers; substituteMainCredential: boolean }
|
|
703
1432
|
| { ok: false; response: Response };
|
|
704
1433
|
|
|
705
1434
|
/**
|
|
@@ -713,10 +1442,40 @@ async function resolveResponsesCodexAuth(
|
|
|
713
1442
|
options: HandleResponsesOptions,
|
|
714
1443
|
): Promise<ResponsesAuthResolution> {
|
|
715
1444
|
try {
|
|
716
|
-
|
|
1445
|
+
// #1686: a caller that proved admission with a BEARER presented one of our own secrets.
|
|
1446
|
+
// Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
|
|
1447
|
+
// Admitting it is only safe because the stored main credential is substituted below, so
|
|
1448
|
+
// the admission secret still never leaves this process.
|
|
1449
|
+
//
|
|
1450
|
+
// #2132: substitution answers "does THIS ROUTE need our stored ChatGPT credential", not
|
|
1451
|
+
// "how did the caller authenticate". Only a native Codex route reaches the ChatGPT backend
|
|
1452
|
+
// and can consume that credential; a key-authenticated routed provider carries its own and
|
|
1453
|
+
// never touches it. Keying on the caller alone made an install that deliberately never
|
|
1454
|
+
// logged into ChatGPT fail every routed request with "No usable Codex main credential".
|
|
1455
|
+
//
|
|
1456
|
+
// But ask that question the way the ADAPTER asks it. `codexAccountMode` is derived from the
|
|
1457
|
+
// provider NAME (`providerCodexAccountMode`), while the passthrough adapter decides whether
|
|
1458
|
+
// to forward caller credentials from the TRANSPORT — adapter, auth mode, and base URL
|
|
1459
|
+
// (`isCanonicalOpenAiForwardProvider`). A row the operator named anything other than
|
|
1460
|
+
// `openai`, pointed at the canonical ChatGPT backend with `authMode: "forward"`, satisfies
|
|
1461
|
+
// the adapter's test and fails this one, so substitution was skipped and the adapter then
|
|
1462
|
+
// forwarded our own admission secret upstream. Two predicates answering one question is the
|
|
1463
|
+
// bug; the transport is the authority, because the transport is what actually carries the
|
|
1464
|
+
// header. A key-authenticated routed provider is still not canonical-forward, so #2132's
|
|
1465
|
+
// no-ChatGPT-login install keeps working.
|
|
1466
|
+
const substituteMainCredential = options.admission?.source === "bearer"
|
|
1467
|
+
&& (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider));
|
|
1468
|
+
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
|
|
1469
|
+
validateForwardAdmissionCredential(req.headers, config);
|
|
1470
|
+
}
|
|
717
1471
|
let authCtx: CodexAuthContext;
|
|
718
1472
|
if (route.codexAccountMode) {
|
|
719
|
-
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
|
|
1473
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
|
|
1474
|
+
accountId: route.codexAccountId,
|
|
1475
|
+
modelId: route.modelId,
|
|
1476
|
+
substituteMainCredentialForDirect: substituteMainCredential,
|
|
1477
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
1478
|
+
});
|
|
720
1479
|
options.onCodexAuthContextResolved?.(authCtx);
|
|
721
1480
|
} else {
|
|
722
1481
|
authCtx = { kind: "main", accountId: null };
|
|
@@ -732,11 +1491,15 @@ async function resolveResponsesCodexAuth(
|
|
|
732
1491
|
return {
|
|
733
1492
|
ok: true,
|
|
734
1493
|
authCtx,
|
|
735
|
-
headers:
|
|
1494
|
+
headers: materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential }),
|
|
1495
|
+
substituteMainCredential,
|
|
736
1496
|
};
|
|
737
1497
|
} catch (err) {
|
|
738
1498
|
if (err instanceof CodexAccountCooldownError) {
|
|
739
|
-
return { ok: false, response: cooldownErrorResponse(err) };
|
|
1499
|
+
return { ok: false, response: cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace) };
|
|
1500
|
+
}
|
|
1501
|
+
if (err instanceof CodexMainProfileDrainingError) {
|
|
1502
|
+
return { ok: false, response: codexMainProfileDrainingResponse() };
|
|
740
1503
|
}
|
|
741
1504
|
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
742
1505
|
return {
|
|
@@ -745,7 +1508,9 @@ async function resolveResponsesCodexAuth(
|
|
|
745
1508
|
};
|
|
746
1509
|
}
|
|
747
1510
|
if (err instanceof CodexAuthContextError) {
|
|
748
|
-
const safeAccountLabel =
|
|
1511
|
+
const safeAccountLabel = route.codexAccountNamespace
|
|
1512
|
+
? `${route.providerName}-${route.codexAccountNamespace}`
|
|
1513
|
+
: formatCodexProviderForLog(route.providerName, err.accountId, config);
|
|
749
1514
|
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
750
1515
|
return {
|
|
751
1516
|
ok: false,
|
|
@@ -761,6 +1526,13 @@ async function resolveResponsesCodexAuth(
|
|
|
761
1526
|
if (err instanceof ForwardAdmissionCredentialError) {
|
|
762
1527
|
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
763
1528
|
}
|
|
1529
|
+
if (err instanceof CodexMainSubstitutionUnavailableError) {
|
|
1530
|
+
// Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret.
|
|
1531
|
+
return {
|
|
1532
|
+
ok: false,
|
|
1533
|
+
response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"),
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
764
1536
|
throw err;
|
|
765
1537
|
}
|
|
766
1538
|
}
|
|
@@ -776,8 +1548,15 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
776
1548
|
req: Request;
|
|
777
1549
|
logCtx: RequestLogContext;
|
|
778
1550
|
inboundWire: InboundWire;
|
|
1551
|
+
inboundTransport?: "websocket";
|
|
779
1552
|
}): Promise<void> {
|
|
780
|
-
const { parsed, route, config, req, logCtx, inboundWire } = args;
|
|
1553
|
+
const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args;
|
|
1554
|
+
|
|
1555
|
+
// Only Anthropic message routes retain the Codex-facing selector. Other providers must keep
|
|
1556
|
+
// their existing response.model contract even when their public and wire model ids differ.
|
|
1557
|
+
const responseModelId = parsed.modelId;
|
|
1558
|
+
const preserveAnthropicResponseModel = route.providerName === "anthropic"
|
|
1559
|
+
|| route.provider.adapter === "anthropic";
|
|
781
1560
|
|
|
782
1561
|
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace.
|
|
783
1562
|
if (route.modelId !== parsed.modelId) {
|
|
@@ -786,32 +1565,88 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
786
1565
|
}
|
|
787
1566
|
parsed.modelId = route.modelId;
|
|
788
1567
|
}
|
|
1568
|
+
// Transport-neutral reliability policy (#875): applies to any Responses
|
|
1569
|
+
// upstream whose final adapter is openai-responses, not only WS turns.
|
|
1570
|
+
const responsesUpstreamStreaming = providerModelResponsesUpstreamStreaming(
|
|
1571
|
+
route.providerName,
|
|
1572
|
+
route.provider,
|
|
1573
|
+
route.modelId,
|
|
1574
|
+
);
|
|
1575
|
+
|
|
789
1576
|
// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
|
|
790
1577
|
// this request will actually use (#404).
|
|
791
1578
|
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
1579
|
+
if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
|
|
792
1580
|
logCtx.model = route.modelId;
|
|
793
1581
|
logCtx.provider = route.providerName;
|
|
794
1582
|
logCtx.providerAdapter = route.provider.adapter;
|
|
1583
|
+
logCtx.routeDecision = route.routeDecision;
|
|
1584
|
+
|
|
1585
|
+
if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") {
|
|
1586
|
+
parsed.stream = false;
|
|
1587
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1588
|
+
(parsed._rawBody as Record<string, unknown>).stream = false;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Generic Responses clients (e.g. AI-SDK apps) omit `store`, but the canonical
|
|
1593
|
+
// forward Codex backend rejects a native request without an explicit store:false.
|
|
1594
|
+
// Default it only there — every other Responses upstream (key-auth providers and
|
|
1595
|
+
// custom forward gateways) intentionally keeps the omitted-store server-side
|
|
1596
|
+
// default for previous_response_id reuse — and never override an explicit value.
|
|
1597
|
+
if (
|
|
1598
|
+
isCanonicalOpenAiForwardProvider(route.provider)
|
|
1599
|
+
&& parsed._rawBody && typeof parsed._rawBody === "object"
|
|
1600
|
+
&& (parsed._rawBody as Record<string, unknown>).store === undefined
|
|
1601
|
+
) {
|
|
1602
|
+
(parsed._rawBody as Record<string, unknown>).store = false;
|
|
1603
|
+
}
|
|
795
1604
|
|
|
796
1605
|
// Final selected model before virtual wire-model rewriting (Pro aliases).
|
|
797
1606
|
const finalSelectedModelId = route.modelId;
|
|
798
1607
|
|
|
799
1608
|
// Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro".
|
|
800
1609
|
applyOpenAiVirtualModel(parsed, route, logCtx);
|
|
1610
|
+
if (parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId) {
|
|
1611
|
+
logCtx.resolvedModel = route.modelId;
|
|
1612
|
+
logCtx.preserveResolvedModelFromRoute = true;
|
|
1613
|
+
}
|
|
801
1614
|
|
|
802
|
-
// Fast
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1615
|
+
// Resolve Fast policy after the final route/wire settles. A1 records the decision on parsed
|
|
1616
|
+
// options; the Responses adapter owns the final outbound body write.
|
|
1617
|
+
const fastPolicy = fastPolicyForModel(
|
|
1618
|
+
route.provider,
|
|
1619
|
+
route.modelId,
|
|
1620
|
+
route.providerName,
|
|
1621
|
+
inboundWire,
|
|
1622
|
+
config.providers[route.providerName],
|
|
1623
|
+
);
|
|
1624
|
+
const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy);
|
|
1625
|
+
const callerTier = parsed.options.serviceTier;
|
|
1626
|
+
parsed.options.tierObservation = tierObservationContext(fastPolicy, config.fastMode, callerTier);
|
|
1627
|
+
parsed.options.tierDecision = decideTier(fastPolicy, config.fastMode, callerTier);
|
|
1628
|
+
parsed.options.serviceTier = tierValueAfterDecision(parsed.options.tierDecision, callerTier);
|
|
1629
|
+
if (fastPolicy.capability === true && fastPolicy.fastWire === null) {
|
|
1630
|
+
warnFastWireCapabilityGap(route.providerName, route.modelId);
|
|
1631
|
+
}
|
|
1632
|
+
applyServiceTierGate(
|
|
1633
|
+
route.provider,
|
|
1634
|
+
parsed._rawBody,
|
|
1635
|
+
parsed.options,
|
|
1636
|
+
route.modelId,
|
|
1637
|
+
route.providerName,
|
|
1638
|
+
inboundWire,
|
|
1639
|
+
fastPolicy,
|
|
1640
|
+
);
|
|
1641
|
+
if (modelServiceTierSupport === false) {
|
|
1642
|
+
logCtx.requestedServiceTier = undefined;
|
|
1643
|
+
logCtx.requestedSpeedLabel = undefined;
|
|
810
1644
|
}
|
|
811
1645
|
|
|
812
1646
|
{
|
|
813
1647
|
const guidance = await multiAgentGuidanceText(parsed, {
|
|
814
1648
|
multiAgentGuidanceEnabled: config.multiAgentGuidanceEnabled,
|
|
1649
|
+
codexAccountNamespace: route.codexAccountNamespace,
|
|
815
1650
|
injectionModel: config.injectionModel,
|
|
816
1651
|
injectionEffort: config.injectionEffort,
|
|
817
1652
|
subagentModels: config.subagentModels,
|
|
@@ -857,10 +1692,9 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
857
1692
|
}
|
|
858
1693
|
}
|
|
859
1694
|
recordAttemptRequestedEffort(logCtx);
|
|
860
|
-
logCtx.modelSupportsServiceTier =
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
);
|
|
1695
|
+
logCtx.modelSupportsServiceTier = SERVICE_TIER_ADAPTERS.has(route.provider.adapter)
|
|
1696
|
+
? modelServiceTierSupport
|
|
1697
|
+
: undefined;
|
|
864
1698
|
}
|
|
865
1699
|
|
|
866
1700
|
|
|
@@ -886,6 +1720,49 @@ export async function handleComboResponses(
|
|
|
886
1720
|
if (!combo) {
|
|
887
1721
|
return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
|
|
888
1722
|
}
|
|
1723
|
+
// Expand previous_response_id before image policy and child dispatch so a
|
|
1724
|
+
// continuation that only references prior images still fails closed when
|
|
1725
|
+
// imageInput is disabled (and so targets see the full replayed input).
|
|
1726
|
+
const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
|
|
1727
|
+
const body = expandPreviousResponseInput(rawBody, inboundClientThreadId);
|
|
1728
|
+
const scopeMismatch = previousResponseScopeMismatch(body);
|
|
1729
|
+
if (scopeMismatch) {
|
|
1730
|
+
console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh");
|
|
1731
|
+
}
|
|
1732
|
+
if (previousResponseReplayFailure(body)) {
|
|
1733
|
+
return formatErrorResponse(
|
|
1734
|
+
400,
|
|
1735
|
+
"previous_response_not_found",
|
|
1736
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
// Missing state returns the original body without a failure marker. Reject
|
|
1740
|
+
// that unresolved continuation for image-disabled combos so a target cannot
|
|
1741
|
+
// resolve prior images out of band. A successful expansion yields a new
|
|
1742
|
+
// object (still carrying previous_response_id) and must not be treated as
|
|
1743
|
+
// unresolved — text-only stored continuations remain allowed.
|
|
1744
|
+
const requestedPreviousId = typeof (rawBody as { previous_response_id?: unknown } | null)?.previous_response_id === "string"
|
|
1745
|
+
? (rawBody as { previous_response_id: string }).previous_response_id.trim()
|
|
1746
|
+
: "";
|
|
1747
|
+
const unresolvedPrevious = requestedPreviousId.length > 0 && body === rawBody;
|
|
1748
|
+
if (combo.imageInput === "disabled" && unresolvedPrevious) {
|
|
1749
|
+
return formatErrorResponse(
|
|
1750
|
+
400,
|
|
1751
|
+
"previous_response_not_found",
|
|
1752
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
|
|
1756
|
+
return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
|
|
1757
|
+
}
|
|
1758
|
+
const comboReplaySnapshot = {
|
|
1759
|
+
sourceBody: body,
|
|
1760
|
+
previousResponseInputExpanded: body !== rawBody
|
|
1761
|
+
&& typeof (body as { previous_response_id?: unknown }).previous_response_id === "string",
|
|
1762
|
+
providerContinuation: !scopeMismatch && body !== rawBody && requestedPreviousId
|
|
1763
|
+
? previousResponseProviderState(requestedPreviousId)
|
|
1764
|
+
: undefined,
|
|
1765
|
+
};
|
|
889
1766
|
const adoptFailedChildLog = (childLog: RequestLogContext): void => {
|
|
890
1767
|
// Attempts remain the complete physical history; the logical row mirrors the most recent
|
|
891
1768
|
// failed target so an exhausted combo still has useful top-level reasoning diagnostics.
|
|
@@ -894,6 +1771,7 @@ export async function handleComboResponses(
|
|
|
894
1771
|
model: requestedModel,
|
|
895
1772
|
provider: "combo",
|
|
896
1773
|
comboId,
|
|
1774
|
+
routeDecision: logCtx.routeDecision,
|
|
897
1775
|
attempts: logCtx.attempts,
|
|
898
1776
|
activeAttempt: undefined,
|
|
899
1777
|
activeAttemptStartedAt: undefined,
|
|
@@ -901,13 +1779,13 @@ export async function handleComboResponses(
|
|
|
901
1779
|
};
|
|
902
1780
|
|
|
903
1781
|
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
904
|
-
(
|
|
1782
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
905
1783
|
);
|
|
906
1784
|
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
|
|
907
1785
|
const provider = config.providers[target.provider];
|
|
908
1786
|
if (!provider || provider.disabled === true) return false;
|
|
909
1787
|
try {
|
|
910
|
-
const route =
|
|
1788
|
+
const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
|
|
911
1789
|
return isCanonicalOpenAiForwardProvider(route.provider);
|
|
912
1790
|
} catch {
|
|
913
1791
|
return false;
|
|
@@ -928,6 +1806,9 @@ export async function handleComboResponses(
|
|
|
928
1806
|
if (!pick) {
|
|
929
1807
|
return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
|
|
930
1808
|
}
|
|
1809
|
+
// One immutable combo selection trace, before any child dispatch; child
|
|
1810
|
+
// adoption below must never replace it with a concrete child route trace.
|
|
1811
|
+
logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
|
|
931
1812
|
|
|
932
1813
|
let lastFailure: Response | null = null;
|
|
933
1814
|
while (pick) {
|
|
@@ -938,9 +1819,9 @@ export async function handleComboResponses(
|
|
|
938
1819
|
...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
|
|
939
1820
|
...(logCtx.surface ? { surface: logCtx.surface } : {}),
|
|
940
1821
|
};
|
|
941
|
-
const targetRoute =
|
|
1822
|
+
const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
942
1823
|
const childBody = concreteComboRequestBody(
|
|
943
|
-
|
|
1824
|
+
body,
|
|
944
1825
|
pick.target,
|
|
945
1826
|
comboDefaultEffort(config, comboId),
|
|
946
1827
|
supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
|
|
@@ -968,6 +1849,7 @@ export async function handleComboResponses(
|
|
|
968
1849
|
attempt,
|
|
969
1850
|
childLog.provider,
|
|
970
1851
|
childLog.providerAdapter ?? attempt.adapter,
|
|
1852
|
+
childLog.accountLogLabel,
|
|
971
1853
|
);
|
|
972
1854
|
finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
|
|
973
1855
|
(logCtx.attempts ??= []).push(attempt);
|
|
@@ -987,6 +1869,7 @@ export async function handleComboResponses(
|
|
|
987
1869
|
response = await handleResponses(childRequest, config, childLog, {
|
|
988
1870
|
...options,
|
|
989
1871
|
comboAttempt: true,
|
|
1872
|
+
comboReplaySnapshot,
|
|
990
1873
|
deferCodexResetDerivedCooldown,
|
|
991
1874
|
// Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
|
|
992
1875
|
// Object.assign(logCtx, childLog) would overwrite the request-relative value).
|
|
@@ -1022,6 +1905,7 @@ export async function handleComboResponses(
|
|
|
1022
1905
|
attempt,
|
|
1023
1906
|
childLog.provider,
|
|
1024
1907
|
childLog.providerAdapter ?? attempt.adapter,
|
|
1908
|
+
childLog.accountLogLabel,
|
|
1025
1909
|
);
|
|
1026
1910
|
(logCtx.attempts ??= []).push(attempt);
|
|
1027
1911
|
attemptRetained = true;
|
|
@@ -1031,6 +1915,7 @@ export async function handleComboResponses(
|
|
|
1031
1915
|
model: requestedModel,
|
|
1032
1916
|
provider: "combo",
|
|
1033
1917
|
comboId,
|
|
1918
|
+
routeDecision: logCtx.routeDecision,
|
|
1034
1919
|
attempts: logCtx.attempts,
|
|
1035
1920
|
activeAttempt: attempt,
|
|
1036
1921
|
activeAttemptStartedAt: started,
|
|
@@ -1066,6 +1951,7 @@ export async function handleComboResponses(
|
|
|
1066
1951
|
attempt,
|
|
1067
1952
|
childLog.provider,
|
|
1068
1953
|
childLog.providerAdapter ?? attempt.adapter,
|
|
1954
|
+
childLog.accountLogLabel,
|
|
1069
1955
|
);
|
|
1070
1956
|
finishRequestAttempt(
|
|
1071
1957
|
attempt,
|
|
@@ -1143,6 +2029,57 @@ function finalizeOwnedTranslatorBudget(response: Response, budget: TranslatorBud
|
|
|
1143
2029
|
return finalizedResponse;
|
|
1144
2030
|
}
|
|
1145
2031
|
|
|
2032
|
+
/**
|
|
2033
|
+
* Service-tier capability gate, applied after the final route/wire is settled. A
|
|
2034
|
+
* provider explicitly documented as NOT supporting `service_tier` must never
|
|
2035
|
+
* receive it: strip the field and clear the logging value even when the caller
|
|
2036
|
+
* supplied one (fail closed). A policy-produced canonical Fast decision has
|
|
2037
|
+
* already passed capability validation and cannot be vetoed by Chat's caller
|
|
2038
|
+
* forwarding permission. On unclassified routes every caller tier remains subject
|
|
2039
|
+
* to `forwardCallerTier`.
|
|
2040
|
+
*/
|
|
2041
|
+
export function applyServiceTierGate(
|
|
2042
|
+
provider: OcxProviderConfig,
|
|
2043
|
+
rawBody: unknown,
|
|
2044
|
+
options: { serviceTier?: string; tierDecision?: TierDecision },
|
|
2045
|
+
modelId?: string,
|
|
2046
|
+
providerName?: string,
|
|
2047
|
+
inbound: InboundWire = "responses",
|
|
2048
|
+
resolvedPolicy?: ResolvedFastPolicy,
|
|
2049
|
+
): void {
|
|
2050
|
+
// A direct unit caller without a model id retains the historical tri-state behavior for
|
|
2051
|
+
// adapters outside the OpenAI service-tier family. Once a model is known, resolve the final
|
|
2052
|
+
// model adapter as well: an explicit override to Anthropic (or another non-OpenAI wire) must
|
|
2053
|
+
// not carry a caller-supplied `service_tier` through a route that cannot forward it.
|
|
2054
|
+
if (modelId === undefined && !SERVICE_TIER_ADAPTERS.has(provider.adapter)) return;
|
|
2055
|
+
const policy = modelId === undefined
|
|
2056
|
+
? undefined
|
|
2057
|
+
: resolvedPolicy ?? fastPolicyForModel(provider, modelId, providerName, inbound);
|
|
2058
|
+
const forwardCallerTier = modelId === undefined
|
|
2059
|
+
? provider.supportsServiceTier !== false
|
|
2060
|
+
: policy!.forwardCallerTier;
|
|
2061
|
+
const rawTier = rawBody && typeof rawBody === "object"
|
|
2062
|
+
? (rawBody as Record<string, unknown>).service_tier
|
|
2063
|
+
: undefined;
|
|
2064
|
+
const canonicalDecision = options.tierDecision?.kind === "set";
|
|
2065
|
+
const callerTierIsForeign = rawTier !== undefined
|
|
2066
|
+
&& (typeof rawTier !== "string" || canonicalFastTierMarker(rawTier) === undefined);
|
|
2067
|
+
const dropForeignCallerTier = policy?.capability === true
|
|
2068
|
+
&& policy.fastWire?.kind === "service-tier"
|
|
2069
|
+
&& policy.fastWire?.foreignCallerTiers === "drop"
|
|
2070
|
+
&& callerTierIsForeign;
|
|
2071
|
+
if (policy && policy.capability !== false && canonicalDecision) return;
|
|
2072
|
+
if (forwardCallerTier && !dropForeignCallerTier) return;
|
|
2073
|
+
if (rawBody && typeof rawBody === "object") {
|
|
2074
|
+
delete (rawBody as Record<string, unknown>).service_tier;
|
|
2075
|
+
}
|
|
2076
|
+
options.serviceTier = undefined;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/**
|
|
2080
|
+
* Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
|
|
2081
|
+
* wire, image/web-search bridges, and the terminal-guard continuation.
|
|
2082
|
+
*/
|
|
1146
2083
|
export async function handleResponses(
|
|
1147
2084
|
req: Request,
|
|
1148
2085
|
config: OcxConfig,
|
|
@@ -1160,39 +2097,66 @@ export async function handleResponses(
|
|
|
1160
2097
|
}
|
|
1161
2098
|
}
|
|
1162
2099
|
|
|
2100
|
+
/**
|
|
2101
|
+
* Inner implementation of `handleResponses`; owns the pre-stream recovery loop and the
|
|
2102
|
+
* per-request same-target 429 retry budgets.
|
|
2103
|
+
*/
|
|
1163
2104
|
async function handleResponsesInner(
|
|
1164
2105
|
req: Request,
|
|
1165
2106
|
config: OcxConfig,
|
|
1166
2107
|
logCtx: RequestLogContext,
|
|
1167
2108
|
options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
|
|
1168
2109
|
): Promise<Response> {
|
|
2110
|
+
let pendingHostAdmissionLease: UpstreamHostAdmissionLease | null = null;
|
|
2111
|
+
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
2112
|
+
try {
|
|
1169
2113
|
// The Chat and Anthropic surfaces replay through here with a Responses-shaped body,
|
|
1170
2114
|
// so an omitted value means a genuine Responses inbound.
|
|
1171
2115
|
const inboundWire = options.inboundWire ?? "responses";
|
|
1172
2116
|
const translatorBudget = options.translatorBudget;
|
|
2117
|
+
const agentTaskRecovery = agentTaskRecoveryConfig(config);
|
|
1173
2118
|
let body: unknown;
|
|
1174
2119
|
try {
|
|
1175
2120
|
body = await readJsonRequestBody(req, translatorBudget);
|
|
1176
2121
|
} catch (err) {
|
|
2122
|
+
if (options.abortSignal?.aborted || req.signal.aborted) {
|
|
2123
|
+
return clientCancelledResponse();
|
|
2124
|
+
}
|
|
1177
2125
|
return decodeRequestErrorResponse(err, "responses");
|
|
1178
2126
|
}
|
|
1179
2127
|
const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
|
|
1180
2128
|
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
1181
|
-
|
|
2129
|
+
options.onRequestBodyRead?.();
|
|
2130
|
+
return handleComboResponses(req, body, comboId, config, logCtx, {
|
|
2131
|
+
...options,
|
|
2132
|
+
// The original request body was accepted above. Combo children are synthetic
|
|
2133
|
+
// replays and must not repeat the caller-owned timeout transition.
|
|
2134
|
+
onRequestBodyRead: undefined,
|
|
2135
|
+
});
|
|
1182
2136
|
}
|
|
1183
|
-
|
|
2137
|
+
let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1184
2138
|
(body as { input?: unknown } | undefined)?.input,
|
|
1185
2139
|
);
|
|
2140
|
+
const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
|
|
1186
2141
|
const originalBody = body;
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
"
|
|
1193
|
-
|
|
2142
|
+
if (options.comboReplaySnapshot) {
|
|
2143
|
+
copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body);
|
|
2144
|
+
} else {
|
|
2145
|
+
body = expandPreviousResponseInput(body, inboundClientThreadId);
|
|
2146
|
+
if (previousResponseScopeMismatch(body)) {
|
|
2147
|
+
console.warn("[opencodex] dropped a previous_response_id with a mismatched client task scope; continuing fresh");
|
|
2148
|
+
}
|
|
2149
|
+
if (previousResponseReplayFailure(body)) {
|
|
2150
|
+
return formatErrorResponse(
|
|
2151
|
+
400,
|
|
2152
|
+
"previous_response_not_found",
|
|
2153
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
2154
|
+
);
|
|
2155
|
+
}
|
|
1194
2156
|
}
|
|
1195
|
-
const previousResponseInputExpanded =
|
|
2157
|
+
const previousResponseInputExpanded = options.comboReplaySnapshot?.previousResponseInputExpanded
|
|
2158
|
+
?? (body !== originalBody
|
|
2159
|
+
&& typeof (body as { previous_response_id?: unknown }).previous_response_id === "string");
|
|
1196
2160
|
|
|
1197
2161
|
// Spawn-message compatibility (both directions): agent_message task payloads ride in
|
|
1198
2162
|
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
@@ -1209,16 +2173,41 @@ async function handleResponsesInner(
|
|
|
1209
2173
|
);
|
|
1210
2174
|
}
|
|
1211
2175
|
|
|
1212
|
-
let parsed;
|
|
2176
|
+
let parsed: OcxParsedRequest;
|
|
1213
2177
|
let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
|
|
1214
2178
|
try {
|
|
1215
2179
|
parsed = parseRequest(body);
|
|
1216
2180
|
toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
|
|
1217
2181
|
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
if (
|
|
2182
|
+
const providerContinuationCandidate = options.comboReplaySnapshot
|
|
2183
|
+
? options.comboReplaySnapshot.providerContinuation
|
|
2184
|
+
: previousResponseProviderState(parsed.previousResponseId);
|
|
2185
|
+
if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
|
|
2186
|
+
if (inboundClientThreadId) {
|
|
2187
|
+
parsed._clientThreadId = inboundClientThreadId;
|
|
2188
|
+
parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId };
|
|
2189
|
+
} else if (
|
|
2190
|
+
options.inboundWire === "anthropic"
|
|
2191
|
+
&& options.promptCacheKeyIsSharedCohort !== true
|
|
2192
|
+
&& typeof parsed.options.promptCacheKey === "string"
|
|
2193
|
+
&& parsed.options.promptCacheKey.trim().length > 0
|
|
2194
|
+
) {
|
|
2195
|
+
// Claude Code has no Codex parent-thread header, but its metadata.user_id is
|
|
2196
|
+
// translated into a stable per-session prompt_cache_key. Use it as the replay
|
|
2197
|
+
// thread identity so Gemini thought signatures are remembered by call_id for
|
|
2198
|
+
// Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so
|
|
2199
|
+
// existing provider session-id derivation (first-user-text fallback) is unchanged.
|
|
2200
|
+
// Normalize through anthropicSessionKeyFromParts so overlong keys are hashed and
|
|
2201
|
+
// trimming matches the affinity/session-key path exactly (no raw >128-char ids).
|
|
2202
|
+
const normalizedCacheKey = anthropicSessionKeyFromParts({
|
|
2203
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
2204
|
+
// The enclosing branch already proves this is not the shared cohort.
|
|
2205
|
+
promptCacheKeyIsSharedCohort: false,
|
|
2206
|
+
});
|
|
2207
|
+
if (normalizedCacheKey) {
|
|
2208
|
+
parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey };
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
1222
2211
|
} catch (err) {
|
|
1223
2212
|
if (isTranslatorBudgetExceededError(err)) {
|
|
1224
2213
|
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
|
|
@@ -1227,6 +2216,11 @@ async function handleResponsesInner(
|
|
|
1227
2216
|
}
|
|
1228
2217
|
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1229
2218
|
}
|
|
2219
|
+
options.onRequestBodyRead?.();
|
|
2220
|
+
const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({
|
|
2221
|
+
...(force ? { force: true } : {}),
|
|
2222
|
+
...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
|
|
2223
|
+
});
|
|
1230
2224
|
// Prefer a pre-populated id (routed Claude) over Responses headers that may be
|
|
1231
2225
|
// absent or synthetically injected (session_id from prompt_cache_key).
|
|
1232
2226
|
if (!logCtx.conversationId) {
|
|
@@ -1239,15 +2233,19 @@ async function handleResponsesInner(
|
|
|
1239
2233
|
}
|
|
1240
2234
|
logCtx.requestedModel = parsed.modelId;
|
|
1241
2235
|
logCtx.requestedEffort = parsed.options.reasoning;
|
|
2236
|
+
logCtx.callerServiceTier = sanitizeLogMetadataString(parsed.options.serviceTier);
|
|
1242
2237
|
logCtx.requestedServiceTier = parsed.options.serviceTier;
|
|
1243
2238
|
logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
|
|
1244
2239
|
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
1245
2240
|
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
1246
2241
|
|
|
1247
|
-
// Shadow call intercept: rewrite Codex
|
|
1248
|
-
//
|
|
2242
|
+
// Shadow call intercept: rewrite Codex 0.145.0+ helper calls (gpt-5.6-luna).
|
|
2243
|
+
// Ancient clients using gpt-5.4-mini remain configurable via sourceModels.
|
|
1249
2244
|
const _sci = config.shadowCallIntercept;
|
|
1250
|
-
if (_sci?.enabled && _sci.model &&
|
|
2245
|
+
if (_sci?.enabled && _sci.model && shouldInterceptShadowCall(
|
|
2246
|
+
parsed.modelId,
|
|
2247
|
+
_sci.sourceModels,
|
|
2248
|
+
)) {
|
|
1251
2249
|
const _sciOriginal = parsed.modelId;
|
|
1252
2250
|
parsed.modelId = _sci.model;
|
|
1253
2251
|
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
@@ -1258,7 +2256,14 @@ async function handleResponsesInner(
|
|
|
1258
2256
|
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1259
2257
|
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
1260
2258
|
}
|
|
1261
|
-
|
|
2259
|
+
// Record the operator-configured prefix that matched, NOT the caller's raw model string.
|
|
2260
|
+
// Matching is by prefix, so a caller can append arbitrary text and still intercept; that
|
|
2261
|
+
// raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor
|
|
2262
|
+
// that does not recognize every credential family. The prefix is a value the operator
|
|
2263
|
+
// configured, so no caller-controlled string is persisted.
|
|
2264
|
+
logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
|
|
2265
|
+
shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels),
|
|
2266
|
+
);
|
|
1262
2267
|
// Helpers must not resume/append into the parent thread's Cursor conversation.
|
|
1263
2268
|
parsed._cursorIsolateConversation = true;
|
|
1264
2269
|
}
|
|
@@ -1266,37 +2271,71 @@ async function handleResponsesInner(
|
|
|
1266
2271
|
|
|
1267
2272
|
let route: RouteResult;
|
|
1268
2273
|
try {
|
|
1269
|
-
route =
|
|
2274
|
+
route = options.comboAttempt
|
|
2275
|
+
? routeConcreteModel(config, parsed.modelId)
|
|
2276
|
+
: routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
|
|
2277
|
+
logCtx.routeDecision = route.routeDecision;
|
|
1270
2278
|
} catch (err) {
|
|
1271
2279
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
1272
2280
|
return comboUnavailableResponse(err.message);
|
|
1273
2281
|
}
|
|
2282
|
+
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
2283
|
+
// Persist the evaluation trace (per-candidate exclusions + the
|
|
2284
|
+
// no-eligible reason) so failed policy requests stay auditable.
|
|
2285
|
+
logCtx.routeDecision = err.trace;
|
|
2286
|
+
}
|
|
1274
2287
|
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1275
2288
|
}
|
|
1276
2289
|
|
|
1277
2290
|
const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
|
|
1278
2291
|
&& parsed._previousResponseInputExpanded !== true;
|
|
1279
|
-
//
|
|
1280
|
-
// Cached fallback state can still select a
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
2292
|
+
// Exact account selectors are isolated from Pool-wide quota work. A canonical replay miss must
|
|
2293
|
+
// also fail closed without polling quota upstream. Cached fallback state can still select a
|
|
2294
|
+
// provider with native continuation support below.
|
|
2295
|
+
const threadSpawn = isThreadSpawnRequest(req.headers);
|
|
2296
|
+
const previewSelectionAdmission = threadSpawn && route.codexAccountId === undefined
|
|
2297
|
+
? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
|
|
2298
|
+
: undefined;
|
|
2299
|
+
const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
|
|
2300
|
+
const nativeMainReadsForbidden = nativeMainRecoveryBlocked
|
|
2301
|
+
|| previewSelectionAdmission?.mainProfileDraining === true;
|
|
2302
|
+
const previewSelectionOptions = {
|
|
2303
|
+
nativeMainSelectionOnly: !nativeMainRecoveryBlocked
|
|
2304
|
+
&& previewSelectionAdmission?.mainProfileDraining === true,
|
|
2305
|
+
};
|
|
1289
2306
|
let selectedForwardHeaders = req.headers;
|
|
1290
2307
|
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
2308
|
+
let subagentFallbackPreviewAccountId: string | null | undefined;
|
|
1291
2309
|
let subagentQuotaFailureModel = parsed.modelId;
|
|
2310
|
+
const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
|
|
2311
|
+
const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;
|
|
1292
2312
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
2313
|
+
try {
|
|
2314
|
+
if (
|
|
2315
|
+
threadSpawn
|
|
2316
|
+
&& route.codexAccountId === undefined
|
|
2317
|
+
&& !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
|
|
2318
|
+
) {
|
|
2319
|
+
await maybePrimeSubagentQuota(config, Date.now(), { nativeMainReadsForbidden });
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// Subagent fallback must settle the final model/provider BEFORE route-dependent
|
|
2323
|
+
// normalization (virtual models, effort caps, service tier, wire protocol).
|
|
2324
|
+
// Preview the preferred Codex account without acquiring a probe lease or refreshing
|
|
1296
2325
|
// tokens — auth is resolved only after the final route is selected.
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
1299
|
-
|
|
2326
|
+
if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) {
|
|
2327
|
+
// The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId),
|
|
2328
|
+
// so the preview must read the same scope slot — an undefined scope would map to the
|
|
2329
|
+
// "legacy" affinity bucket and never find a binding made under "shared" or a native
|
|
2330
|
+
// model scope, making the preview diverge from the account that actually authenticates.
|
|
2331
|
+
const previewAccountId = previewCodexAccountForRequest(
|
|
2332
|
+
poolAffinityKey,
|
|
2333
|
+
config,
|
|
2334
|
+
Date.now(),
|
|
2335
|
+
codexQuotaScopeForModel(route.modelId),
|
|
2336
|
+
previewSelectionOptions,
|
|
2337
|
+
);
|
|
2338
|
+
subagentFallbackPreviewAccountId = previewAccountId;
|
|
1300
2339
|
subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
|
|
1301
2340
|
const fallback = applySubagentModelFallback(
|
|
1302
2341
|
parsed,
|
|
@@ -1305,6 +2344,7 @@ async function handleResponsesInner(
|
|
|
1305
2344
|
previewAccountId,
|
|
1306
2345
|
Date.now(),
|
|
1307
2346
|
unreadableEncryptedAgentTask,
|
|
2347
|
+
previewSelectionOptions,
|
|
1308
2348
|
);
|
|
1309
2349
|
if (fallback) {
|
|
1310
2350
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
@@ -1317,15 +2357,120 @@ async function handleResponsesInner(
|
|
|
1317
2357
|
|
|
1318
2358
|
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
1319
2359
|
try {
|
|
1320
|
-
route = routeModel(config, fallback.to);
|
|
2360
|
+
route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
|
|
2361
|
+
logCtx.routeDecision = route.routeDecision;
|
|
1321
2362
|
} catch (err) {
|
|
1322
2363
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
1323
2364
|
return comboUnavailableResponse(err.message);
|
|
1324
2365
|
}
|
|
2366
|
+
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
2367
|
+
logCtx.routeDecision = err.trace;
|
|
2368
|
+
}
|
|
1325
2369
|
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1326
2370
|
}
|
|
1327
2371
|
}
|
|
1328
2372
|
}
|
|
2373
|
+
} finally {
|
|
2374
|
+
previewSelectionAdmission?.release();
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// Native fallback can consume ciphertext, so recover only after final route selection.
|
|
2378
|
+
if (
|
|
2379
|
+
inboundWire === "responses"
|
|
2380
|
+
&&
|
|
2381
|
+
threadSpawn
|
|
2382
|
+
&& unreadableEncryptedAgentTask
|
|
2383
|
+
&& agentTaskRecovery
|
|
2384
|
+
&& !isCanonicalOpenAiForwardProvider(route.provider)
|
|
2385
|
+
&& !options.comboAttempt
|
|
2386
|
+
) {
|
|
2387
|
+
let recovered = false;
|
|
2388
|
+
try {
|
|
2389
|
+
recovered = await recoverEncryptedAgentTask(
|
|
2390
|
+
req,
|
|
2391
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
2392
|
+
agentTaskRecovery,
|
|
2393
|
+
config,
|
|
2394
|
+
{ parentThreadId, abortSignal: options.abortSignal },
|
|
2395
|
+
);
|
|
2396
|
+
} catch {
|
|
2397
|
+
recovered = false;
|
|
2398
|
+
}
|
|
2399
|
+
if (recovered) {
|
|
2400
|
+
unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
2401
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
2402
|
+
);
|
|
2403
|
+
if (!unreadableEncryptedAgentTask) {
|
|
2404
|
+
try {
|
|
2405
|
+
const reparsed = parseRequest(body);
|
|
2406
|
+
const kept: Array<keyof OcxParsedRequest> = [
|
|
2407
|
+
"_previousResponseInputExpanded",
|
|
2408
|
+
"_providerContinuation",
|
|
2409
|
+
"_providerContinuationCandidate",
|
|
2410
|
+
"_providerContinuationOwner",
|
|
2411
|
+
"_cursorConversationId",
|
|
2412
|
+
"_clientThreadId",
|
|
2413
|
+
"_reasoningReplayScope",
|
|
2414
|
+
"_cursorIsolateConversation",
|
|
2415
|
+
];
|
|
2416
|
+
for (const key of kept) {
|
|
2417
|
+
if (parsed[key] !== undefined) {
|
|
2418
|
+
(reparsed as unknown as Record<string, unknown>)[key] = parsed[key];
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
parsed = reparsed;
|
|
2422
|
+
// The recovery mutated `body.input` in place, so `_rawBody` now carries decrypted task
|
|
2423
|
+
// text. Bar it from the continuation cache before any recording path can reach it —
|
|
2424
|
+
// that cache is persisted to disk, which would defeat the recovery cache's TTL.
|
|
2425
|
+
markBodyNonPersistable(parsed._rawBody);
|
|
2426
|
+
|
|
2427
|
+
// The ciphertext-only pass intentionally excludes routed candidates. Once recovery
|
|
2428
|
+
// makes the assignment readable, run selection again with the full configured chain
|
|
2429
|
+
// and keep the route in sync with any newly selected fallback.
|
|
2430
|
+
const fallback = applySubagentModelFallback(
|
|
2431
|
+
parsed,
|
|
2432
|
+
req.headers,
|
|
2433
|
+
config,
|
|
2434
|
+
subagentFallbackPreviewAccountId,
|
|
2435
|
+
Date.now(),
|
|
2436
|
+
false,
|
|
2437
|
+
previewSelectionOptions,
|
|
2438
|
+
);
|
|
2439
|
+
if (fallback) {
|
|
2440
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
2441
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
2442
|
+
if (isInjectionDebugEnabled()) {
|
|
2443
|
+
injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
|
|
2447
|
+
|
|
2448
|
+
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
2449
|
+
try {
|
|
2450
|
+
route = routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody));
|
|
2451
|
+
logCtx.routeDecision = route.routeDecision;
|
|
2452
|
+
} catch (err) {
|
|
2453
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
2454
|
+
return comboUnavailableResponse(err.message);
|
|
2455
|
+
}
|
|
2456
|
+
if (err instanceof NoEligiblePolicyCandidateError) {
|
|
2457
|
+
logCtx.routeDecision = err.trace;
|
|
2458
|
+
}
|
|
2459
|
+
return formatErrorResponse(
|
|
2460
|
+
404,
|
|
2461
|
+
"invalid_request_error",
|
|
2462
|
+
err instanceof Error ? err.message : String(err),
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
} catch {
|
|
2467
|
+
unreadableEncryptedAgentTask = true;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1329
2474
|
|
|
1330
2475
|
// Encrypted child tasks may only reach the canonical native backend. This check
|
|
1331
2476
|
// runs against the FINAL route so native-only fallback can rescue a routed primary.
|
|
@@ -1347,19 +2492,80 @@ async function handleResponsesInner(
|
|
|
1347
2492
|
);
|
|
1348
2493
|
}
|
|
1349
2494
|
|
|
1350
|
-
|
|
2495
|
+
// Captured before normalization: whether the CLIENT asked for SSE. The
|
|
2496
|
+
// transport-neutral upstream-streaming policy below may force a bounded JSON
|
|
2497
|
+
// upstream for reliability (#875); the answer must then be reframed to SSE
|
|
2498
|
+
// for streaming clients.
|
|
2499
|
+
const clientRequestedStream = parsed.stream;
|
|
2500
|
+
await applyFinalRouteRequestNormalization({
|
|
2501
|
+
parsed,
|
|
2502
|
+
route,
|
|
2503
|
+
config,
|
|
2504
|
+
req,
|
|
2505
|
+
logCtx,
|
|
2506
|
+
inboundWire,
|
|
2507
|
+
inboundTransport: options.inboundTransport,
|
|
2508
|
+
});
|
|
2509
|
+
// Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
|
|
2510
|
+
// the normal post-resolution provider label is assigned.
|
|
2511
|
+
if (route.codexAccountNamespace) {
|
|
2512
|
+
logCtx.provider = `${route.providerName}-${route.codexAccountNamespace}`;
|
|
2513
|
+
}
|
|
1351
2514
|
|
|
2515
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
2516
|
+
// Refuse an input that cannot plausibly fit the model context window before spending auth,
|
|
2517
|
+
// circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412).
|
|
2518
|
+
//
|
|
2519
|
+
// Compaction turns are exempt: Codex sends compaction_trigger BECAUSE context is full, so
|
|
2520
|
+
// refusing the turn that shrinks the context would deadlock the client against the very
|
|
2521
|
+
// limit this gate reports — it would be told to compact and then denied the compaction.
|
|
2522
|
+
if (parsed._compactionRequest !== true) {
|
|
2523
|
+
const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
|
|
2524
|
+
if (!inputAdmission.admitted) {
|
|
2525
|
+
// #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
|
|
2526
|
+
// fallback must be able to skip this candidate and try one whose context window fits,
|
|
2527
|
+
// instead of treating the first incompatible candidate as the end of the chain. The
|
|
2528
|
+
// distinct code is what lets the fallback layer tell the two apart -- an upstream
|
|
2529
|
+
// `context_length_exceeded` still stops, because retrying it elsewhere is guesswork.
|
|
2530
|
+
return formatErrorResponse(
|
|
2531
|
+
413,
|
|
2532
|
+
"input_admission_refused",
|
|
2533
|
+
`Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
|
|
2534
|
+
+ `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
|
|
2535
|
+
+ `model with a larger context window.`,
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
const preAuthHostKey = preAuthUpstreamHostCircuitKey(route, config);
|
|
2540
|
+
if (preAuthHostKey) {
|
|
2541
|
+
const admission = acquireUpstreamHostAdmission(
|
|
2542
|
+
preAuthHostKey,
|
|
2543
|
+
config.upstreamHostCircuitThreshold,
|
|
2544
|
+
);
|
|
2545
|
+
if (admission.kind === "blocked") {
|
|
2546
|
+
return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds);
|
|
2547
|
+
}
|
|
2548
|
+
pendingHostAdmissionLease = admission.lease;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
let substituteMainCredential = false;
|
|
1352
2552
|
{
|
|
1353
2553
|
const finalAuth = await resolveResponsesCodexAuth(req, config, route, options);
|
|
1354
2554
|
if (!finalAuth.ok) return finalAuth.response;
|
|
1355
2555
|
authCtx = finalAuth.authCtx;
|
|
1356
2556
|
selectedForwardHeaders = finalAuth.headers;
|
|
2557
|
+
substituteMainCredential = finalAuth.substituteMainCredential;
|
|
1357
2558
|
}
|
|
1358
2559
|
|
|
1359
2560
|
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
2561
|
+
applyCodexAccountGatedWireNormalization(parsed, route);
|
|
2562
|
+
logCtx.provider = route.codexAccountNamespace
|
|
2563
|
+
? `${route.providerName}-${route.codexAccountNamespace}`
|
|
2564
|
+
: formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
2565
|
+
logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
|
|
2566
|
+
// Seed an account-derived scope before final adapter binding. Cursor never treats it as
|
|
2567
|
+
// authoritative: bindRouteReasoningReplayScope replaces it with the exact route owner or a
|
|
2568
|
+
// per-request fail-closed sentinel after the final provider and credential are known.
|
|
1363
2569
|
const identityScope = codexLogAccountId(authCtx);
|
|
1364
2570
|
if (identityScope) parsed._cursorIdentityScope = identityScope;
|
|
1365
2571
|
subagentFallbackAccountId = authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
@@ -1371,6 +2577,7 @@ async function handleResponsesInner(
|
|
|
1371
2577
|
const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
|
|
1372
2578
|
&& route.provider.authMode === "oauth";
|
|
1373
2579
|
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
2580
|
+
let replayOAuthCredentialSnapshot: Pick<OAuthAccessSnapshot, "accountId" | "generation"> | undefined;
|
|
1374
2581
|
let anthropicPoolAccountId: string | null = null;
|
|
1375
2582
|
let anthropicPoolFailovers = 0;
|
|
1376
2583
|
const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
|
|
@@ -1406,6 +2613,10 @@ async function handleResponsesInner(
|
|
|
1406
2613
|
logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
|
|
1407
2614
|
} else {
|
|
1408
2615
|
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
2616
|
+
replayOAuthCredentialSnapshot = {
|
|
2617
|
+
accountId: resolved.accountId,
|
|
2618
|
+
generation: resolved.generation,
|
|
2619
|
+
};
|
|
1409
2620
|
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
1410
2621
|
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
1411
2622
|
if (route.providerName === "kiro") {
|
|
@@ -1414,21 +2625,23 @@ async function handleResponsesInner(
|
|
|
1414
2625
|
parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
|
|
1415
2626
|
}
|
|
1416
2627
|
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
1417
|
-
// CCA envelope
|
|
2628
|
+
// CCA envelope. Keep it paired with the token snapshot so an account rotation cannot mix
|
|
2629
|
+
// a fresh token with project metadata re-read from a different credential generation.
|
|
1418
2630
|
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
1419
|
-
const projectId =
|
|
2631
|
+
const projectId = resolved.projectId;
|
|
1420
2632
|
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
1421
2633
|
}
|
|
1422
2634
|
}
|
|
1423
2635
|
} catch (err) {
|
|
1424
2636
|
if (err instanceof UnsupportedOAuthProviderError) {
|
|
2637
|
+
const safeProviderName = redactSecretString(route.providerName);
|
|
1425
2638
|
return formatErrorResponse(
|
|
1426
2639
|
400,
|
|
1427
2640
|
"invalid_request_error",
|
|
1428
|
-
`${err.message}. Remove or reconfigure provider '${
|
|
2641
|
+
`${redactSecretString(err.message)}. Remove or reconfigure provider '${safeProviderName}' in the OpenCodex configuration.`,
|
|
1429
2642
|
);
|
|
1430
2643
|
}
|
|
1431
|
-
return formatErrorResponse(401, "authentication_error",
|
|
2644
|
+
return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
|
|
1432
2645
|
}
|
|
1433
2646
|
}
|
|
1434
2647
|
route.provider = resolveProviderTransport(
|
|
@@ -1437,10 +2650,70 @@ async function handleResponsesInner(
|
|
|
1437
2650
|
parsed.options.promptCacheKey,
|
|
1438
2651
|
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
1439
2652
|
);
|
|
1440
|
-
|
|
2653
|
+
let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
2654
|
+
const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true
|
|
2655
|
+
&& adapterProvider.adapter === "openai-responses"
|
|
2656
|
+
&& adapterProvider.authMode === "forward"
|
|
2657
|
+
&& !isCanonicalOpenAiForwardProvider(adapterProvider);
|
|
2658
|
+
if (stripClaudeMainAuth) {
|
|
2659
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
2660
|
+
authCtx = { kind: "main", accountId: null };
|
|
2661
|
+
route.provider = stripCodexRuntimeProviderFields(route.provider);
|
|
2662
|
+
adapterProvider = stripCodexRuntimeProviderFields(adapterProvider);
|
|
2663
|
+
selectedForwardHeaders = new Headers(selectedForwardHeaders);
|
|
2664
|
+
selectedForwardHeaders.delete("authorization");
|
|
2665
|
+
selectedForwardHeaders.delete("chatgpt-account-id");
|
|
2666
|
+
delete route.codexAccountMode;
|
|
2667
|
+
delete route.codexAccountId;
|
|
2668
|
+
delete route.codexAccountNamespace;
|
|
2669
|
+
logCtx.provider = route.providerName;
|
|
2670
|
+
delete logCtx.accountLogLabel;
|
|
2671
|
+
}
|
|
1441
2672
|
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
2673
|
+
bindRouteReasoningReplayScope({
|
|
2674
|
+
parsed,
|
|
2675
|
+
providerName: route.providerName,
|
|
2676
|
+
provider: adapterProvider,
|
|
2677
|
+
adapterName: adapter.name,
|
|
2678
|
+
oauthCredentialSnapshot: replayOAuthCredentialSnapshot,
|
|
2679
|
+
codexAuthContext: authCtx,
|
|
2680
|
+
forwardHeaders: selectedForwardHeaders,
|
|
2681
|
+
});
|
|
2682
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
2683
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
2684
|
+
}
|
|
1442
2685
|
logCtx.providerAdapter = adapter.name;
|
|
1443
|
-
|
|
2686
|
+
// Ordinary requests receive one durable attempt only after their final initial
|
|
2687
|
+
// adapter is resolved. Combo children own their attempt and retries keep it.
|
|
2688
|
+
if (!options.comboAttempt && !logCtx.activeAttempt) {
|
|
2689
|
+
const attempt = beginRequestAttempt(
|
|
2690
|
+
(logCtx.attempts?.length ?? 0) + 1,
|
|
2691
|
+
logCtx.provider,
|
|
2692
|
+
route.modelId,
|
|
2693
|
+
adapter.name,
|
|
2694
|
+
);
|
|
2695
|
+
logCtx.activeAttempt = attempt;
|
|
2696
|
+
logCtx.activeAttemptStartedAt = Date.now();
|
|
2697
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
2698
|
+
}
|
|
2699
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel);
|
|
2700
|
+
if (adapter.runTurn) {
|
|
2701
|
+
recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed));
|
|
2702
|
+
}
|
|
2703
|
+
// Optional route-identity linkage for attempt correlation (CL-09 consumes it). The slot
|
|
2704
|
+
// resolves to null unless an opt-in subsystem registered a linker, so an install without
|
|
2705
|
+
// routing profiles does no work here and loads no additional module. The non-throwing
|
|
2706
|
+
// guarantee lives in the slot helper.
|
|
2707
|
+
if (logCtx.activeAttempt && !logCtx.activeAttempt.labRouteSubjectId) {
|
|
2708
|
+
const passiveSubjectId = resolvePassiveRouteSubjectId(
|
|
2709
|
+
config,
|
|
2710
|
+
route.providerName,
|
|
2711
|
+
route.modelId,
|
|
2712
|
+
route.provider,
|
|
2713
|
+
inboundWire,
|
|
2714
|
+
);
|
|
2715
|
+
if (passiveSubjectId) logCtx.activeAttempt.labRouteSubjectId = passiveSubjectId;
|
|
2716
|
+
}
|
|
1444
2717
|
const isPassthrough = "passthrough" in adapter && !!adapter.passthrough;
|
|
1445
2718
|
|
|
1446
2719
|
if (adapter.name === "kiro" && parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
@@ -1460,6 +2733,14 @@ async function handleResponsesInner(
|
|
|
1460
2733
|
listOpenAiForwardSidecarCandidates(config),
|
|
1461
2734
|
req.headers,
|
|
1462
2735
|
config,
|
|
2736
|
+
{
|
|
2737
|
+
// Account-qualified native routes are passthrough, so their in-turn helper is vision.
|
|
2738
|
+
// Scope its cooldown and outcome to the helper model, not the routed text model.
|
|
2739
|
+
...(route.codexAccountId !== undefined
|
|
2740
|
+
? { exactAccount: { accountId: route.codexAccountId, modelId: resolveOpenAiVisionModel(config) } }
|
|
2741
|
+
: {}),
|
|
2742
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
2743
|
+
},
|
|
1463
2744
|
);
|
|
1464
2745
|
} catch (err) {
|
|
1465
2746
|
// Sidecars are optional helpers for an otherwise independent routed turn.
|
|
@@ -1470,6 +2751,7 @@ async function handleResponsesInner(
|
|
|
1470
2751
|
&& !(err instanceof CodexAuthContextError)
|
|
1471
2752
|
&& !(err instanceof CodexAccountCooldownError)
|
|
1472
2753
|
&& !(err instanceof CodexThreadAffinityExpiredError)
|
|
2754
|
+
&& !(err instanceof CodexMainProfileDrainingError)
|
|
1473
2755
|
) throw err;
|
|
1474
2756
|
}
|
|
1475
2757
|
}
|
|
@@ -1477,7 +2759,15 @@ async function handleResponsesInner(
|
|
|
1477
2759
|
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
1478
2760
|
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
1479
2761
|
// call, so the text-only model can reason about it.
|
|
1480
|
-
|
|
2762
|
+
// Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe
|
|
2763
|
+
// call must never plan another describe. The flag arrives from the Chat
|
|
2764
|
+
// surface (whose bridge rebuilds headers) or as the raw header for native
|
|
2765
|
+
// Responses callers. Marked + text-only routed model → strip, depth cap 1.
|
|
2766
|
+
const visionDescribeTerminal = options.visionDescribeTerminal === true
|
|
2767
|
+
|| req.headers.get("x-opencodex-vision-describe") === "1";
|
|
2768
|
+
const visionPlan = visionDescribeTerminal
|
|
2769
|
+
? undefined
|
|
2770
|
+
: planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
|
|
1481
2771
|
const recordSidecarOutcome = openAiSidecar?.recordOutcome;
|
|
1482
2772
|
if (visionPlan) {
|
|
1483
2773
|
await describeImagesInPlace(
|
|
@@ -1488,7 +2778,7 @@ async function handleResponsesInner(
|
|
|
1488
2778
|
recordSidecarOutcome,
|
|
1489
2779
|
translatorBudget,
|
|
1490
2780
|
);
|
|
1491
|
-
} else if (
|
|
2781
|
+
} else if (isModelTextOnly(route.provider, route.modelId)) {
|
|
1492
2782
|
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
1493
2783
|
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
1494
2784
|
stripImagesInPlace(parsed, translatorBudget);
|
|
@@ -1500,24 +2790,19 @@ async function handleResponsesInner(
|
|
|
1500
2790
|
emitted?: OcxProviderContinuationState,
|
|
1501
2791
|
): OcxProviderContinuationState | undefined => {
|
|
1502
2792
|
const cursorConversationId = parsed._cursorConversationId;
|
|
1503
|
-
const inherited = parsed._providerContinuation;
|
|
1504
|
-
|
|
1505
|
-
return
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
...(cursorConversationId
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
conversationId: cursorConversationId,
|
|
1517
|
-
},
|
|
1518
|
-
}
|
|
1519
|
-
: {}),
|
|
1520
|
-
};
|
|
2793
|
+
const inherited = providerContinuationPayload(parsed._providerContinuation);
|
|
2794
|
+
const emittedPayload = providerContinuationPayload(emitted);
|
|
2795
|
+
if (!emittedPayload && !inherited && !cursorConversationId) return undefined;
|
|
2796
|
+
const merged = mergeProviderContinuationPayload(
|
|
2797
|
+
inherited ?? {},
|
|
2798
|
+
emittedPayload ?? {},
|
|
2799
|
+
) as OcxProviderContinuationState;
|
|
2800
|
+
if (cursorConversationId) {
|
|
2801
|
+
merged.cursor = { ...(merged.cursor ?? {}), conversationId: cursorConversationId };
|
|
2802
|
+
}
|
|
2803
|
+
return parsed._providerContinuationOwner
|
|
2804
|
+
? { ...merged, __ocxOwner: { ...parsed._providerContinuationOwner } }
|
|
2805
|
+
: merged;
|
|
1521
2806
|
};
|
|
1522
2807
|
|
|
1523
2808
|
// Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
|
|
@@ -1530,18 +2815,40 @@ async function handleResponsesInner(
|
|
|
1530
2815
|
// message, and leave Codex fataling on a missing compaction item (#422).
|
|
1531
2816
|
const routedCompaction = parsed._compactionRequest === true
|
|
1532
2817
|
&& !isCanonicalOpenAiForwardProvider(route.provider);
|
|
2818
|
+
const commitReasoningReplayServingRoute = (): void => {
|
|
2819
|
+
commitReasoningReplayServingIdentity(parsed._reasoningReplayScope);
|
|
2820
|
+
};
|
|
1533
2821
|
if (routedCompaction) {
|
|
1534
2822
|
delete parsed.context.tools;
|
|
1535
2823
|
delete parsed._webSearch;
|
|
1536
2824
|
delete parsed.options.toolChoice;
|
|
1537
2825
|
delete parsed.options.parallelToolCalls;
|
|
2826
|
+
// The compaction turn is a plain prose summary; a surviving structured-output format
|
|
2827
|
+
// would force schema-constrained JSON into the synthetic compaction item. The flag and
|
|
2828
|
+
// the raw `text` controls go too: Kiro's capability guard reads both and would reject
|
|
2829
|
+
// the turn outright, and the key-mode openai-responses adapter builds from _rawBody.
|
|
2830
|
+
delete parsed.options.textFormat;
|
|
2831
|
+
delete parsed._structuredOutput;
|
|
2832
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
2833
|
+
delete (parsed._rawBody as Record<string, unknown>).text;
|
|
2834
|
+
}
|
|
1538
2835
|
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
|
|
1539
2836
|
}
|
|
1540
2837
|
|
|
2838
|
+
let routedNamespaceToolAliases: RoutedNamespaceToolAliases = new Map();
|
|
2839
|
+
const refreshRoutedNamespaceToolAliases = (builtRequest: AdapterRequest): void => {
|
|
2840
|
+
routedNamespaceToolAliases = builtRequest.convertedRoutedNamespaceToolAliases ?? new Map();
|
|
2841
|
+
};
|
|
2842
|
+
|
|
1541
2843
|
if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
|
|
2844
|
+
let hostAdmissionLease = pendingHostAdmissionLease;
|
|
2845
|
+
pendingHostAdmissionLease = null;
|
|
2846
|
+
try {
|
|
1542
2847
|
const imageGenCallAliases = route.provider.authMode === "forward"
|
|
1543
2848
|
? new Map<string, { namespace: string; name: string }>()
|
|
1544
2849
|
: imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
|
|
2850
|
+
const routedCustomToolNames = new Set<string>();
|
|
2851
|
+
const routedToolSearchNames = new Set<string>();
|
|
1545
2852
|
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
1546
2853
|
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
1547
2854
|
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
@@ -1555,7 +2862,7 @@ async function handleResponsesInner(
|
|
|
1555
2862
|
&& (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
|
|
1556
2863
|
const rememberPassthroughResponse = passthroughRecordEligible
|
|
1557
2864
|
? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
|
|
1558
|
-
rememberResponseState(parsed._rawBody, response, undefined,
|
|
2865
|
+
rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true))
|
|
1559
2866
|
: undefined;
|
|
1560
2867
|
if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
1561
2868
|
console.warn(
|
|
@@ -1563,9 +2870,142 @@ async function handleResponsesInner(
|
|
|
1563
2870
|
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
1564
2871
|
);
|
|
1565
2872
|
}
|
|
1566
|
-
let request
|
|
2873
|
+
let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
|
|
2874
|
+
try {
|
|
2875
|
+
request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
2878
|
+
// A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and
|
|
2879
|
+
// the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing
|
|
2880
|
+
// it here escaped every catch up to the Bun handler, so the same request produced an
|
|
2881
|
+
// unstructured 500 — and no request log — depending only on whether a rotation ran first.
|
|
2882
|
+
if (error instanceof NamespaceToolCollisionError) {
|
|
2883
|
+
return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
|
|
2884
|
+
}
|
|
2885
|
+
throw error;
|
|
2886
|
+
}
|
|
2887
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
2888
|
+
for (const name of request.convertedRoutedCustomToolNames ?? []) {
|
|
2889
|
+
if (
|
|
2890
|
+
toolBridgeMaps.freeformToolNames.has(name)
|
|
2891
|
+
|| toolBridgeMaps.toolNsMap.get(name)?.freeform === true
|
|
2892
|
+
) routedCustomToolNames.add(name);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
for (const name of request.convertedRoutedToolSearchNames ?? []) {
|
|
2896
|
+
// The adapter already keeps this set empty when tool_choice forbids the private search.
|
|
2897
|
+
// Its wire name may be collision-aliased, so comparing it to the caller-facing name here
|
|
2898
|
+
// would incorrectly disable restoration for the exact ambiguous-name case the alias fixes.
|
|
2899
|
+
routedToolSearchNames.add(name);
|
|
2900
|
+
}
|
|
2901
|
+
refreshRoutedNamespaceToolAliases(request);
|
|
2902
|
+
// #1700: the bridged paths refuse a call to a tool the request never declared
|
|
2903
|
+
// (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed
|
|
2904
|
+
// provider's top-level `apply_patch` — which under Codex code mode exists only as a nested
|
|
2905
|
+
// `tools.apply_patch(...)` helper inside `exec`, never as a wire tool — reached Codex as a
|
|
2906
|
+
// call it cannot execute, and the turn showed a bare `aborted` with the file untouched.
|
|
2907
|
+
// Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a
|
|
2908
|
+
// routed provider, so it keeps passing through unguarded, as it does for the rewrites above.
|
|
2909
|
+
// The guard needs a catalog to compare against, so it stands down when the request carries
|
|
2910
|
+
// none. That is not the same claim as "no tools means no tool may be called": a passthrough
|
|
2911
|
+
// request can legitimately omit `tools` entirely and still receive a tool call the client
|
|
2912
|
+
// understands — `tests/github-copilot-stream-contract.test.ts` sends `{model, input, stream}`
|
|
2913
|
+
// with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an
|
|
2914
|
+
// empty catalog truncates that turn. An unreadable body lands here too, since it yields no
|
|
2915
|
+
// names either.
|
|
2916
|
+
const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
|
|
2917
|
+
try {
|
|
2918
|
+
const body = JSON.parse(bodyText) as unknown;
|
|
2919
|
+
return body && typeof body === "object" && !Array.isArray(body)
|
|
2920
|
+
? body as Record<string, unknown>
|
|
2921
|
+
: undefined;
|
|
2922
|
+
} catch {
|
|
2923
|
+
return undefined;
|
|
2924
|
+
}
|
|
2925
|
+
};
|
|
2926
|
+
let outboundRequestBody = parseOutboundRequestBody(request.body);
|
|
2927
|
+
const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody);
|
|
2928
|
+
// Union with the caller's own catalog, because the outbound body is not always a complete
|
|
2929
|
+
// record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a
|
|
2930
|
+
// request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets
|
|
2931
|
+
// the client's name back. Widening only ever makes the guard fire less; a name declared in
|
|
2932
|
+
// neither place — #1700's `apply_patch` — is still refused.
|
|
2933
|
+
for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
|
|
2934
|
+
const undeclaredToolGuardActive = declaredWireToolNames.size > 0
|
|
2935
|
+
&& route.provider.authMode !== "forward";
|
|
2936
|
+
// A refused turn must not seed `previous_response_id` replay. The inspection branch reads the
|
|
2937
|
+
// untouched upstream stream, so it can still observe a `response.completed` the client never
|
|
2938
|
+
// received; checking the payload itself rather than a flag shared with the client relay keeps
|
|
2939
|
+
// this free of tee ordering races.
|
|
2940
|
+
//
|
|
2941
|
+
// Checking only the terminal snapshot is not enough. An upstream can announce the undeclared
|
|
2942
|
+
// call in `response.output_item.added`, which trips the client guard, and then close with a
|
|
2943
|
+
// `response.completed` whose `output` is empty. The client gets `response.failed`, the terminal
|
|
2944
|
+
// check sees nothing undeclared, and the refused turn enters continuation state anyway. So the
|
|
2945
|
+
// rejection is sticky for the whole turn, set from every parsed payload on the inspection side.
|
|
2946
|
+
let inspectionSawUndeclaredTool = false;
|
|
2947
|
+
const noteInspectedPayload = (payload: unknown) => {
|
|
2948
|
+
// Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth
|
|
2949
|
+
// provider) every name looks undeclared, and flipping this would stop recording continuation
|
|
2950
|
+
// state for exactly the passthrough traffic the guard deliberately stands down for.
|
|
2951
|
+
if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return;
|
|
2952
|
+
if (undeclaredToolCallName(payload, declaredWireToolNames) !== undefined) {
|
|
2953
|
+
inspectionSawUndeclaredTool = true;
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
const rememberPassthroughResponseChecked = rememberPassthroughResponse
|
|
2957
|
+
? (response: { id?: unknown; output?: unknown; status?: unknown }) => {
|
|
2958
|
+
if (inspectionSawUndeclaredTool) return;
|
|
2959
|
+
if (
|
|
2960
|
+
undeclaredToolGuardActive
|
|
2961
|
+
&& undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined
|
|
2962
|
+
) {
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
rememberPassthroughResponse(response);
|
|
2966
|
+
}
|
|
2967
|
+
: undefined;
|
|
1567
2968
|
recordAdapterReasoning(logCtx, request);
|
|
1568
|
-
|
|
2969
|
+
recordAdapterTier(logCtx, request);
|
|
2970
|
+
const actualHostKey = upstreamHostHealthKey(
|
|
2971
|
+
route.providerName,
|
|
2972
|
+
safeOriginLabel(request.url),
|
|
2973
|
+
);
|
|
2974
|
+
const hostKey = route.provider.authMode === "forward"
|
|
2975
|
+
? actualHostKey
|
|
2976
|
+
: null;
|
|
2977
|
+
const hostCircuitEnabled = hostKey !== null
|
|
2978
|
+
&& normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0;
|
|
2979
|
+
if (hostKey !== null && !hostCircuitEnabled) {
|
|
2980
|
+
disableUpstreamHostCircuitForKey(actualHostKey);
|
|
2981
|
+
}
|
|
2982
|
+
if (hostAdmissionLease && hostAdmissionLease.key !== hostKey) {
|
|
2983
|
+
return formatErrorResponse(502, "upstream_error", "Provider host changed after circuit admission");
|
|
2984
|
+
}
|
|
2985
|
+
if (options.abortSignal?.aborted) {
|
|
2986
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
2987
|
+
return clientCancelledResponse();
|
|
2988
|
+
}
|
|
2989
|
+
if (!hostAdmissionLease && hostCircuitEnabled) {
|
|
2990
|
+
const admission = acquireUpstreamHostAdmission(
|
|
2991
|
+
hostKey!,
|
|
2992
|
+
config.upstreamHostCircuitThreshold,
|
|
2993
|
+
);
|
|
2994
|
+
if (admission.kind === "blocked") {
|
|
2995
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
2996
|
+
return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds);
|
|
2997
|
+
}
|
|
2998
|
+
hostAdmissionLease = admission.lease;
|
|
2999
|
+
}
|
|
3000
|
+
const settleObservedHostResponse = (): void => {
|
|
3001
|
+
if (hostCircuitEnabled) {
|
|
3002
|
+
resetUpstreamHostHealth(actualHostKey, hostAdmissionLease);
|
|
3003
|
+
} else {
|
|
3004
|
+
resetUpstreamHostHealth(actualHostKey);
|
|
3005
|
+
}
|
|
3006
|
+
hostAdmissionLease = null;
|
|
3007
|
+
};
|
|
3008
|
+
let passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
1569
3009
|
? request.usageLog.inputTokens
|
|
1570
3010
|
: undefined;
|
|
1571
3011
|
if (passthroughEstimate !== undefined) {
|
|
@@ -1580,11 +3020,35 @@ async function handleResponsesInner(
|
|
|
1580
3020
|
let upstreamResponse: Response;
|
|
1581
3021
|
const transportFailureResponse = (err: unknown): Response => {
|
|
1582
3022
|
upstream.abort();
|
|
1583
|
-
if (options.abortSignal?.aborted)
|
|
1584
|
-
|
|
3023
|
+
if (options.abortSignal?.aborted) {
|
|
3024
|
+
releaseUpstreamHostAdmission(hostAdmissionLease);
|
|
3025
|
+
hostAdmissionLease = null;
|
|
3026
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
3027
|
+
return clientCancelledResponse();
|
|
3028
|
+
}
|
|
3029
|
+
const outcome = classifyTransportFailureKind(err);
|
|
3030
|
+
// Host-level evidence stands regardless of pool membership: a direct
|
|
3031
|
+
// forward send has no pool accounting, but the reachability failure is
|
|
3032
|
+
// still host-wide, not account evidence (#914 review).
|
|
3033
|
+
if (outcome === "connect_neutral") {
|
|
3034
|
+
if (hostCircuitEnabled) {
|
|
3035
|
+
recordUpstreamHostFailure(actualHostKey, {
|
|
3036
|
+
code: transportErrorCode(err),
|
|
3037
|
+
threshold: config.upstreamHostCircuitThreshold,
|
|
3038
|
+
lease: hostAdmissionLease,
|
|
3039
|
+
});
|
|
3040
|
+
} else {
|
|
3041
|
+
recordUpstreamHostFailure(actualHostKey, { code: transportErrorCode(err) });
|
|
3042
|
+
}
|
|
3043
|
+
hostAdmissionLease = null;
|
|
3044
|
+
} else {
|
|
3045
|
+
releaseUpstreamHostAdmission(hostAdmissionLease);
|
|
3046
|
+
hostAdmissionLease = null;
|
|
3047
|
+
}
|
|
1585
3048
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1586
3049
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1587
|
-
threadId:
|
|
3050
|
+
threadId: authCtx.affinityKey,
|
|
3051
|
+
fixedAccount: authCtx.fixedAccount,
|
|
1588
3052
|
modelId: route.modelId,
|
|
1589
3053
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1590
3054
|
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
@@ -1607,7 +3071,18 @@ async function handleResponsesInner(
|
|
|
1607
3071
|
method: request.method,
|
|
1608
3072
|
headers: request.headers,
|
|
1609
3073
|
body: request.body,
|
|
1610
|
-
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
3074
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
3075
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
3076
|
+
providerName: route.providerName,
|
|
3077
|
+
modelId: route.modelId,
|
|
3078
|
+
}),
|
|
3079
|
+
route.provider.authMode === "forward")
|
|
3080
|
+
// Every real attempt response — including an intermediate 5xx the
|
|
3081
|
+
// retry wrapper replaces — proves the host was reached (#914 review).
|
|
3082
|
+
.then(res => {
|
|
3083
|
+
settleObservedHostResponse();
|
|
3084
|
+
return res;
|
|
3085
|
+
});
|
|
1611
3086
|
},
|
|
1612
3087
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
1613
3088
|
);
|
|
@@ -1617,6 +3092,260 @@ async function handleResponsesInner(
|
|
|
1617
3092
|
request.releaseBodyObservation?.();
|
|
1618
3093
|
}
|
|
1619
3094
|
|
|
3095
|
+
const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false };
|
|
3096
|
+
let oauth401ReplayAttempted = false;
|
|
3097
|
+
const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
|
|
3098
|
+
let rateLimitRetries = 0;
|
|
3099
|
+
const rebuildAndRefetch = async (
|
|
3100
|
+
recovery: AttemptRecoveryKind,
|
|
3101
|
+
): Promise<Response | { failed: Response }> => {
|
|
3102
|
+
const retryAdapter = resolveAdapter(
|
|
3103
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
3104
|
+
config.cacheRetention,
|
|
3105
|
+
);
|
|
3106
|
+
if (!("passthrough" in retryAdapter) || !retryAdapter.passthrough) {
|
|
3107
|
+
upstream.abort();
|
|
3108
|
+
return { failed: formatErrorResponse(502, "upstream_error", "Recovery changed the provider wire unexpectedly") };
|
|
3109
|
+
}
|
|
3110
|
+
try {
|
|
3111
|
+
request = await retryAdapter.buildRequest(parsed, {
|
|
3112
|
+
headers: selectedForwardHeaders,
|
|
3113
|
+
translatorBudget,
|
|
3114
|
+
});
|
|
3115
|
+
refreshRoutedNamespaceToolAliases(request);
|
|
3116
|
+
recordAdapterReasoning(logCtx, request);
|
|
3117
|
+
recordAdapterTier(logCtx, request);
|
|
3118
|
+
} catch (err) {
|
|
3119
|
+
upstream.abort();
|
|
3120
|
+
if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() };
|
|
3121
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3122
|
+
return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) };
|
|
3123
|
+
}
|
|
3124
|
+
passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
3125
|
+
? request.usageLog.inputTokens
|
|
3126
|
+
: undefined;
|
|
3127
|
+
if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate;
|
|
3128
|
+
outboundRequestBody = parseOutboundRequestBody(request.body);
|
|
3129
|
+
logCtx.providerAdapter = retryAdapter.name;
|
|
3130
|
+
sealRequestAttemptIdentity(
|
|
3131
|
+
logCtx.activeAttempt,
|
|
3132
|
+
logCtx.provider,
|
|
3133
|
+
retryAdapter.name,
|
|
3134
|
+
logCtx.accountLogLabel,
|
|
3135
|
+
);
|
|
3136
|
+
try {
|
|
3137
|
+
return await fetchWithTransientRetry(
|
|
3138
|
+
innerRecovery => {
|
|
3139
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery);
|
|
3140
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
3141
|
+
method: request.method,
|
|
3142
|
+
headers: request.headers,
|
|
3143
|
+
body: request.body,
|
|
3144
|
+
}, innerRecovery), upstream.signal, connectMs, parsed.stream,
|
|
3145
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
3146
|
+
providerName: route.providerName,
|
|
3147
|
+
modelId: route.modelId,
|
|
3148
|
+
}),
|
|
3149
|
+
route.provider.authMode === "forward")
|
|
3150
|
+
.then(response => {
|
|
3151
|
+
settleObservedHostResponse();
|
|
3152
|
+
return response;
|
|
3153
|
+
});
|
|
3154
|
+
},
|
|
3155
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
3156
|
+
);
|
|
3157
|
+
} catch (err) {
|
|
3158
|
+
return { failed: transportFailureResponse(err) };
|
|
3159
|
+
} finally {
|
|
3160
|
+
request.releaseBodyObservation?.();
|
|
3161
|
+
}
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3164
|
+
// Keep recovery kinds in sync with the generic `recovery:` loop below.
|
|
3165
|
+
passthroughRecovery: for (;;) {
|
|
3166
|
+
|
|
3167
|
+
// Native Responses providers return before the generic adapter recovery loop below. Keep
|
|
3168
|
+
// their OAuth contract identical: one pre-stream 401 forces a credential refresh and one
|
|
3169
|
+
// rebuilt replay. xAI's current subscription models use this branch now that their official
|
|
3170
|
+
// Grok CLI catalog declares the Responses backend.
|
|
3171
|
+
if (
|
|
3172
|
+
upstreamResponse.status === 401
|
|
3173
|
+
&& isOAuth401ReplayProvider
|
|
3174
|
+
&& sentOAuthSnapshot
|
|
3175
|
+
&& !oauth401ReplayAttempted
|
|
3176
|
+
) {
|
|
3177
|
+
oauth401ReplayAttempted = true;
|
|
3178
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
3179
|
+
let refreshed: OAuthAccessSnapshot;
|
|
3180
|
+
try {
|
|
3181
|
+
refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
|
|
3182
|
+
} catch (err) {
|
|
3183
|
+
upstream.abort();
|
|
3184
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
3185
|
+
return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
|
|
3186
|
+
}
|
|
3187
|
+
sentOAuthSnapshot = refreshed;
|
|
3188
|
+
replayOAuthCredentialSnapshot = {
|
|
3189
|
+
accountId: refreshed.accountId,
|
|
3190
|
+
generation: refreshed.generation,
|
|
3191
|
+
};
|
|
3192
|
+
if (route.providerName === "kiro") {
|
|
3193
|
+
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
3194
|
+
}
|
|
3195
|
+
const refreshedProvider = resolveProviderTransport(
|
|
3196
|
+
route.providerName,
|
|
3197
|
+
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
3198
|
+
parsed.options.promptCacheKey,
|
|
3199
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
3200
|
+
);
|
|
3201
|
+
route.provider = refreshedProvider;
|
|
3202
|
+
const refreshedAdapter = resolveAdapter(
|
|
3203
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
|
|
3204
|
+
config.cacheRetention,
|
|
3205
|
+
);
|
|
3206
|
+
if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) {
|
|
3207
|
+
upstream.abort();
|
|
3208
|
+
return formatErrorResponse(502, "upstream_error", "OAuth refresh changed the provider wire unexpectedly");
|
|
3209
|
+
}
|
|
3210
|
+
bindRouteReasoningReplayScope({
|
|
3211
|
+
parsed,
|
|
3212
|
+
providerName: route.providerName,
|
|
3213
|
+
provider: refreshedProvider,
|
|
3214
|
+
adapterName: refreshedAdapter.name,
|
|
3215
|
+
oauthCredentialSnapshot: replayOAuthCredentialSnapshot,
|
|
3216
|
+
});
|
|
3217
|
+
logCtx.providerAdapter = refreshedAdapter.name;
|
|
3218
|
+
sealRequestAttemptIdentity(
|
|
3219
|
+
logCtx.activeAttempt,
|
|
3220
|
+
logCtx.provider,
|
|
3221
|
+
refreshedAdapter.name,
|
|
3222
|
+
logCtx.accountLogLabel,
|
|
3223
|
+
);
|
|
3224
|
+
try {
|
|
3225
|
+
request = await refreshedAdapter.buildRequest(parsed, {
|
|
3226
|
+
headers: selectedForwardHeaders,
|
|
3227
|
+
translatorBudget,
|
|
3228
|
+
});
|
|
3229
|
+
refreshRoutedNamespaceToolAliases(request);
|
|
3230
|
+
recordAdapterReasoning(logCtx, request);
|
|
3231
|
+
recordAdapterTier(logCtx, request);
|
|
3232
|
+
} catch (err) {
|
|
3233
|
+
upstream.abort();
|
|
3234
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
3235
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3236
|
+
return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
|
|
3237
|
+
}
|
|
3238
|
+
try {
|
|
3239
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
3240
|
+
recovery => {
|
|
3241
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401");
|
|
3242
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
3243
|
+
method: request.method,
|
|
3244
|
+
headers: request.headers,
|
|
3245
|
+
body: request.body,
|
|
3246
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
3247
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
3248
|
+
providerName: route.providerName,
|
|
3249
|
+
modelId: route.modelId,
|
|
3250
|
+
}),
|
|
3251
|
+
route.provider.authMode === "forward")
|
|
3252
|
+
.then(res => {
|
|
3253
|
+
settleObservedHostResponse();
|
|
3254
|
+
return res;
|
|
3255
|
+
});
|
|
3256
|
+
},
|
|
3257
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
3258
|
+
);
|
|
3259
|
+
} catch (err) {
|
|
3260
|
+
return transportFailureResponse(err);
|
|
3261
|
+
} finally {
|
|
3262
|
+
request.releaseBodyObservation?.();
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the
|
|
3267
|
+
// passthrough wire. This branch returns before the recovery loop below, so Responses-shaped
|
|
3268
|
+
// key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429
|
|
3269
|
+
// immediately with no same-key replay. Pre-stream only — nothing has been relayed yet, so
|
|
3270
|
+
// the replay is lossless (same invariant as the recovery loop). Forward/OAuth providers
|
|
3271
|
+
// keep their pool logic below (rateLimitRetryPolicyFor returns null for them).
|
|
3272
|
+
while (
|
|
3273
|
+
upstreamResponse.status === 429
|
|
3274
|
+
&& rateLimitPolicy !== null
|
|
3275
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
3276
|
+
) {
|
|
3277
|
+
rateLimitRetries += 1;
|
|
3278
|
+
// Release unread body + deliberate wait via the shared same-target helper.
|
|
3279
|
+
const retryAfterHeader = upstreamResponse.headers.get("retry-after");
|
|
3280
|
+
try {
|
|
3281
|
+
for await (const _ of prepareSameTarget429Wait({
|
|
3282
|
+
body: upstreamResponse.body,
|
|
3283
|
+
signal: options.abortSignal,
|
|
3284
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
3285
|
+
})) {
|
|
3286
|
+
// pre-stream: no stall watchdog to feed
|
|
3287
|
+
}
|
|
3288
|
+
} catch {
|
|
3289
|
+
upstream.abort();
|
|
3290
|
+
return clientCancelledResponse();
|
|
3291
|
+
}
|
|
3292
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
3293
|
+
// replay so the wire never starts work for a request the client already abandoned.
|
|
3294
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
3295
|
+
upstream.abort();
|
|
3296
|
+
return clientCancelledResponse();
|
|
3297
|
+
}
|
|
3298
|
+
try {
|
|
3299
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
3300
|
+
recovery => {
|
|
3301
|
+
// The first send of every replay is itself a rate-limit retry; inner transient-5xx
|
|
3302
|
+
// recoveries keep their own label (recovery is provided for those).
|
|
3303
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429");
|
|
3304
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
3305
|
+
method: request.method,
|
|
3306
|
+
headers: request.headers,
|
|
3307
|
+
body: request.body,
|
|
3308
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
3309
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
3310
|
+
providerName: route.providerName,
|
|
3311
|
+
modelId: route.modelId,
|
|
3312
|
+
}),
|
|
3313
|
+
route.provider.authMode === "forward")
|
|
3314
|
+
.then(res => {
|
|
3315
|
+
settleObservedHostResponse();
|
|
3316
|
+
return res;
|
|
3317
|
+
});
|
|
3318
|
+
},
|
|
3319
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
3320
|
+
);
|
|
3321
|
+
} catch (err) {
|
|
3322
|
+
return transportFailureResponse(err);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
const captureAffinityResponse = (
|
|
3327
|
+
response: Response,
|
|
3328
|
+
captureAuthCtx: CodexAuthContext = authCtx,
|
|
3329
|
+
captureRequest: Awaited<ReturnType<typeof adapter.buildRequest>> = request,
|
|
3330
|
+
credentialSubstituted = substituteMainCredential
|
|
3331
|
+
|| captureAuthCtx.kind === "pool"
|
|
3332
|
+
|| captureAuthCtx.kind === "main-pool",
|
|
3333
|
+
): void => {
|
|
3334
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
|
|
3335
|
+
captureCodexAffinityDiagnostic({
|
|
3336
|
+
inboundHeaders: req.headers,
|
|
3337
|
+
outboundHeaders: captureRequest.headers,
|
|
3338
|
+
authKind: captureAuthCtx.kind,
|
|
3339
|
+
accountMode: route.codexAccountMode,
|
|
3340
|
+
fixedAccount: isFixedCodexAccount(captureAuthCtx),
|
|
3341
|
+
credentialSubstituted,
|
|
3342
|
+
accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId),
|
|
3343
|
+
wireModelNormalized: parsed.modelId !== route.modelId,
|
|
3344
|
+
status: response.status,
|
|
3345
|
+
});
|
|
3346
|
+
};
|
|
3347
|
+
captureAffinityResponse(upstreamResponse);
|
|
3348
|
+
|
|
1620
3349
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1621
3350
|
let poolRetryOutcome: number | undefined;
|
|
1622
3351
|
if (await shouldRetryCodexPoolAccountModel400(
|
|
@@ -1625,7 +3354,7 @@ async function handleResponsesInner(
|
|
|
1625
3354
|
options.abortSignal,
|
|
1626
3355
|
)) {
|
|
1627
3356
|
poolRetryOutcome = 400;
|
|
1628
|
-
} else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
3357
|
+
} else if (!authCtx.fixedAccount && shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
1629
3358
|
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
1630
3359
|
poolRetryOutcome = upstreamResponse.status;
|
|
1631
3360
|
}
|
|
@@ -1645,6 +3374,9 @@ async function handleResponsesInner(
|
|
|
1645
3374
|
connectMs,
|
|
1646
3375
|
passthroughEstimate,
|
|
1647
3376
|
stream: parsed.stream,
|
|
3377
|
+
onResponse: (response, retryAuthCtx, retryRequest) => {
|
|
3378
|
+
captureAffinityResponse(response, retryAuthCtx, retryRequest, true);
|
|
3379
|
+
},
|
|
1648
3380
|
});
|
|
1649
3381
|
if (retry.kind === "transport") {
|
|
1650
3382
|
authCtx = retry.authCtx;
|
|
@@ -1653,6 +3385,7 @@ async function handleResponsesInner(
|
|
|
1653
3385
|
if (retry.kind === "retried") {
|
|
1654
3386
|
authCtx = retry.authCtx;
|
|
1655
3387
|
request = retry.request;
|
|
3388
|
+
refreshRoutedNamespaceToolAliases(request);
|
|
1656
3389
|
upstreamResponse = retry.upstreamResponse;
|
|
1657
3390
|
selectedForwardHeaders = retry.selectedForwardHeaders;
|
|
1658
3391
|
// Keep subagent quota-failure health keyed to the account that actually served.
|
|
@@ -1660,6 +3393,25 @@ async function handleResponsesInner(
|
|
|
1660
3393
|
}
|
|
1661
3394
|
}
|
|
1662
3395
|
}
|
|
3396
|
+
// The deterministic route record cannot classify history it never observed (restart, expiry,
|
|
3397
|
+
// eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound
|
|
3398
|
+
// Responses body still carries opaque state, then rebuild once through the ordinary adapter
|
|
3399
|
+
// sanitation path. A second rejection falls through unchanged because the guard stays armed.
|
|
3400
|
+
const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({
|
|
3401
|
+
response: upstreamResponse,
|
|
3402
|
+
outboundBody: request.body,
|
|
3403
|
+
adapterName: adapter.name,
|
|
3404
|
+
parsed,
|
|
3405
|
+
guard: opaqueBlobRecoveryGuard,
|
|
3406
|
+
signal: upstream.signal,
|
|
3407
|
+
}, rebuildAndRefetch);
|
|
3408
|
+
if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response;
|
|
3409
|
+
if (opaqueBlobRecovery.kind === "recovered") {
|
|
3410
|
+
upstreamResponse = opaqueBlobRecovery.response;
|
|
3411
|
+
continue passthroughRecovery;
|
|
3412
|
+
}
|
|
3413
|
+
break;
|
|
3414
|
+
}
|
|
1663
3415
|
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
1664
3416
|
const resolvedModel = headers.get("openai-model")?.trim();
|
|
1665
3417
|
if (resolvedModel) logCtx.resolvedModel = resolvedModel;
|
|
@@ -1678,14 +3430,13 @@ async function handleResponsesInner(
|
|
|
1678
3430
|
route.provider,
|
|
1679
3431
|
route.modelId,
|
|
1680
3432
|
logCtx,
|
|
1681
|
-
req.headers.get("x-codex-parent-thread-id"),
|
|
1682
3433
|
);
|
|
1683
3434
|
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
1684
3435
|
// Capture quota from upstream response for multi-account tracking
|
|
1685
3436
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1686
3437
|
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
1687
3438
|
// Prefer primary when present, fall back to secondary for compatibility.
|
|
1688
|
-
const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
|
|
3439
|
+
const quotaMeta = { ...codexQuotaOutcomeMeta(upstreamResponse), ...(await codexDenialOutcomeMeta(upstreamResponse)) };
|
|
1689
3440
|
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
1690
3441
|
applyAccountQuotaFromUpstreamHeaders(
|
|
1691
3442
|
authCtx.accountId,
|
|
@@ -1701,7 +3452,7 @@ async function handleResponsesInner(
|
|
|
1701
3452
|
|| logCtx.terminalHttpStatus === 402
|
|
1702
3453
|
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
1703
3454
|
: undefined;
|
|
1704
|
-
if (quotaFailureMessage !== undefined) {
|
|
3455
|
+
if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) {
|
|
1705
3456
|
recordSubagentQuotaFailureForThreadSpawn(
|
|
1706
3457
|
req.headers,
|
|
1707
3458
|
subagentQuotaFailureModel,
|
|
@@ -1719,7 +3470,8 @@ async function handleResponsesInner(
|
|
|
1719
3470
|
)) {
|
|
1720
3471
|
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
1721
3472
|
...quotaMeta,
|
|
1722
|
-
threadId:
|
|
3473
|
+
threadId: authCtx.affinityKey,
|
|
3474
|
+
fixedAccount: authCtx.fixedAccount,
|
|
1723
3475
|
modelId: route.modelId,
|
|
1724
3476
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1725
3477
|
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
@@ -1732,17 +3484,42 @@ async function handleResponsesInner(
|
|
|
1732
3484
|
// Codex renders that as the opaque "Unknown error" (#452). Combo attempts
|
|
1733
3485
|
// keep their typed failure envelope. Non-empty bodies are relayed verbatim
|
|
1734
3486
|
// (headers included) so pool-retry Activation B/D and client diagnostics stay intact.
|
|
3487
|
+
// Manual-redirect policy (#914): a 3xx is relayed as-is (Location preserved
|
|
3488
|
+
// through sanitizePassthroughHeaders) so a redirect to a dead host can never
|
|
3489
|
+
// masquerade as a pre-connection failure after the credential was seen.
|
|
3490
|
+
// The numeric outcome above already classified it neutral — no streak.
|
|
3491
|
+
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
|
3492
|
+
return new Response(upstreamResponse.body, {
|
|
3493
|
+
status: upstreamResponse.status,
|
|
3494
|
+
statusText: upstreamResponse.statusText,
|
|
3495
|
+
headers: sanitizePassthroughHeaders(upstreamResponse.headers),
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
1735
3498
|
if (!upstreamResponse.ok) {
|
|
1736
3499
|
if (options.comboAttempt) {
|
|
3500
|
+
// No pre-read guard here: `consumeComboFailure` -> `readBoundedResponseBody` reads
|
|
3501
|
+
// `response.body` itself and already threads the abort signal through its own read,
|
|
3502
|
+
// and the combo contract is that this body's getter is touched exactly once (pinned by
|
|
3503
|
+
// "captures passthrough failed usage from its original bounded body exactly once").
|
|
3504
|
+
// Attaching a guard would be a second `.body` access and break that contract for no
|
|
3505
|
+
// gain, since the bounded reader owns settlement on this path.
|
|
1737
3506
|
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
|
|
1738
3507
|
options.onConsumedComboFailure?.(failure);
|
|
1739
3508
|
return failure.response;
|
|
1740
3509
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
3510
|
+
// The plain passthrough error path has no bounded reader of its own: `.text()` attaches
|
|
3511
|
+
// the reader only when it runs, so an abort landing between fetch resolution and that
|
|
3512
|
+
// call orphans Bun's internal read rejection (src/lib/abort.ts).
|
|
3513
|
+
const detachPassthroughErrorGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
3514
|
+
try {
|
|
3515
|
+
const errorText = await upstreamResponse.text().catch(() => "");
|
|
3516
|
+
return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
|
|
3517
|
+
statusText: upstreamResponse.statusText,
|
|
3518
|
+
headers,
|
|
3519
|
+
});
|
|
3520
|
+
} finally {
|
|
3521
|
+
detachPassthroughErrorGuard();
|
|
3522
|
+
}
|
|
1746
3523
|
}
|
|
1747
3524
|
|
|
1748
3525
|
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
|
|
@@ -1754,18 +3531,80 @@ async function handleResponsesInner(
|
|
|
1754
3531
|
// `streamMode: "eager-relay"` opt-in. Darwin `auto` always stays tee. The
|
|
1755
3532
|
// eager shape skips tee and uses one bounded reader with inline inspection
|
|
1756
3533
|
// (src/server/relay-eager.ts; policy:
|
|
1757
|
-
// devlog/
|
|
3534
|
+
// devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md).
|
|
1758
3535
|
// The bundled known-bad runtime remains on tee by default on both platforms.
|
|
1759
3536
|
if (isEventStream && upstreamResponse.body) {
|
|
3537
|
+
// For streamed passthrough, a successful terminal response means non-error upstream status
|
|
3538
|
+
// before relay starts. Waiting for SSE completion would retain request state across the whole
|
|
3539
|
+
// stream; a later body failure does not undo that this destination accepted and served the turn.
|
|
3540
|
+
commitReasoningReplayServingRoute();
|
|
3541
|
+
const terminalRepairPolicy = providerModelResponsesTerminalRepair(
|
|
3542
|
+
route.providerName,
|
|
3543
|
+
route.provider,
|
|
3544
|
+
route.modelId,
|
|
3545
|
+
);
|
|
3546
|
+
const passthroughSseBody = terminalRepairPolicy
|
|
3547
|
+
? relayResponsesSseWithTerminalRepair(
|
|
3548
|
+
upstreamResponse.body,
|
|
3549
|
+
upstream,
|
|
3550
|
+
terminalRepairPolicy,
|
|
3551
|
+
translatorBudget,
|
|
3552
|
+
options.responsesTerminalRepairScheduler,
|
|
3553
|
+
)
|
|
3554
|
+
: upstreamResponse.body;
|
|
1760
3555
|
const repairConfig = route.provider.responsesItemIdRepair;
|
|
1761
|
-
const
|
|
3556
|
+
const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair);
|
|
3557
|
+
const githubCopilotRepairEnabled = route.providerName === "github-copilot";
|
|
3558
|
+
const responseModelRewrite = parsed._responseModelId !== undefined
|
|
3559
|
+
&& parsed._responseModelId !== parsed.modelId
|
|
3560
|
+
? createResponsesModelPayloadRewrite(parsed._responseModelId)
|
|
3561
|
+
: undefined;
|
|
1762
3562
|
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
|
|
1763
3563
|
const payloadRewrites = [
|
|
1764
3564
|
createImageGenCallRestoreRewrite(imageGenCallAliases),
|
|
3565
|
+
routedNamespaceToolAliases.size > 0
|
|
3566
|
+
? createRoutedNamespaceCallRestoreRewrite(routedNamespaceToolAliases)
|
|
3567
|
+
: undefined,
|
|
1765
3568
|
hasResponsesItemIdRepair(repairConfig)
|
|
1766
3569
|
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
|
|
1767
3570
|
: undefined,
|
|
3571
|
+
responseModelRewrite,
|
|
3572
|
+
parsed.options.hideThinkingSummary !== true
|
|
3573
|
+
&& routeUsesContentChannelReasoning(route.provider, route.modelId)
|
|
3574
|
+
? createReasoningSummaryChannelPayloadRewrite()
|
|
3575
|
+
: undefined,
|
|
3576
|
+
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
3577
|
+
// #893: sparse-snapshot gateways get field backfills AND lifecycle event
|
|
3578
|
+
// injection at the block level, after payload rewrites. Defaults come
|
|
3579
|
+
// from the finalized OUTBOUND body — the normalized internal tool shapes
|
|
3580
|
+
// are not the Responses wire shapes the snapshot must mirror.
|
|
3581
|
+
const blockRewrites = [
|
|
3582
|
+
payloadRewrites.length > 0
|
|
3583
|
+
? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites))
|
|
3584
|
+
: undefined,
|
|
3585
|
+
routedCustomToolNames.size > 0
|
|
3586
|
+
? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget)
|
|
3587
|
+
: undefined,
|
|
3588
|
+
routedToolSearchNames.size > 0
|
|
3589
|
+
? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget)
|
|
3590
|
+
: undefined,
|
|
3591
|
+
githubCopilotRepairEnabled
|
|
3592
|
+
? createGithubCopilotResponsesBlockRewrite(translatorBudget)
|
|
3593
|
+
: undefined,
|
|
3594
|
+
snapshotRepairEnabled
|
|
3595
|
+
? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget)
|
|
3596
|
+
: undefined,
|
|
3597
|
+
createResponsesFieldBackfillBlockRewrite(),
|
|
3598
|
+
// Last: every rewrite above can still rename or reshape a call item, so the guard must
|
|
3599
|
+
// compare the names the client will actually receive against the declared catalog.
|
|
3600
|
+
undeclaredToolGuardActive
|
|
3601
|
+
? createUndeclaredToolCallGuardBlockRewrite(declaredWireToolNames)
|
|
3602
|
+
: undefined,
|
|
1768
3603
|
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
3604
|
+
const clientBlockRewrite = blockRewrites.length > 0
|
|
3605
|
+
? composeSseBlockRewrites(...blockRewrites)
|
|
3606
|
+
: undefined;
|
|
3607
|
+
const needsClientRewrite = clientBlockRewrite !== undefined;
|
|
1769
3608
|
// #864: win32 rewrite traffic must never enter the tee()+JS-pull chain
|
|
1770
3609
|
// (Bun#32111 JS-sink segfault — text frames pass, the terminal block is
|
|
1771
3610
|
// lost). The eager single reader applies the same rewrites inline.
|
|
@@ -1775,7 +3614,14 @@ async function handleResponsesInner(
|
|
|
1775
3614
|
needsClientRewrite,
|
|
1776
3615
|
config.streamMode ?? "auto",
|
|
1777
3616
|
);
|
|
1778
|
-
|
|
3617
|
+
// A successful Codex WS upgrade is a push source. If it entered tee(),
|
|
3618
|
+
// the inspection branch could drain continuously while the slow client
|
|
3619
|
+
// branch retained bytes without a bound. Force the existing bounded,
|
|
3620
|
+
// single-reader relay before tee; HTTP fallback responses stay unmarked.
|
|
3621
|
+
const forceCodexWsEagerRelay = isCodexWsUpstreamResponse(upstreamResponse);
|
|
3622
|
+
const inlineEagerRewrite = needsClientRewrite
|
|
3623
|
+
&& (forceCodexWsEagerRelay || win32EagerRewrite || eagerPath?.useEagerRelay === true);
|
|
3624
|
+
if (forceCodexWsEagerRelay || eagerPath?.useEagerRelay || win32EagerRewrite) {
|
|
1779
3625
|
const turnAc = new AbortController();
|
|
1780
3626
|
linkAbortSignal(upstream, turnAc.signal);
|
|
1781
3627
|
registerTurn(turnAc, options.turnAdmissionLease);
|
|
@@ -1788,7 +3634,7 @@ async function handleResponsesInner(
|
|
|
1788
3634
|
|| logCtx.terminalHttpStatus === 402
|
|
1789
3635
|
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
1790
3636
|
: undefined;
|
|
1791
|
-
if (quotaFailureMessage !== undefined) {
|
|
3637
|
+
if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) {
|
|
1792
3638
|
recordSubagentQuotaFailureForThreadSpawn(
|
|
1793
3639
|
req.headers,
|
|
1794
3640
|
subagentQuotaFailureModel,
|
|
@@ -1804,16 +3650,20 @@ async function handleResponsesInner(
|
|
|
1804
3650
|
const inspector = createSseInspector({
|
|
1805
3651
|
onTerminal: reportNativeTerminal,
|
|
1806
3652
|
logCtx,
|
|
1807
|
-
onCompletedResponse:
|
|
3653
|
+
onCompletedResponse: rememberPassthroughResponseChecked,
|
|
3654
|
+
onParsedPayload: noteInspectedPayload,
|
|
1808
3655
|
onFirstOutput: options.onFirstOutput,
|
|
3656
|
+
pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled,
|
|
1809
3657
|
});
|
|
1810
|
-
const eagerBody = relaySseEagerBounded(
|
|
3658
|
+
const eagerBody = relaySseEagerBounded(passthroughSseBody, turnAc, {
|
|
1811
3659
|
inspectChunk: chunk => inspector.feed(chunk),
|
|
1812
3660
|
finishInspection: () => inspector.finish(),
|
|
1813
3661
|
disposeInspection: () => inspector.dispose(),
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
3662
|
+
// Stream lifetime follows the protocol terminal even when this request
|
|
3663
|
+
// has no outcome callback configured (reported() would stay false).
|
|
3664
|
+
sawTerminal: () => inspector.terminalSeen(),
|
|
3665
|
+
...(clientBlockRewrite
|
|
3666
|
+
? { rewriteBlocks: clientBlockRewrite }
|
|
1817
3667
|
: {}),
|
|
1818
3668
|
onSynthetic: kind => {
|
|
1819
3669
|
if (!reportNativeTerminal) return;
|
|
@@ -1823,15 +3673,17 @@ async function handleResponsesInner(
|
|
|
1823
3673
|
} else {
|
|
1824
3674
|
logCtx.transportPhase = "mid_stream";
|
|
1825
3675
|
logCtx.terminalSource = "synthetic";
|
|
3676
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true;
|
|
1826
3677
|
reportNativeTerminal("failed", 502);
|
|
1827
3678
|
}
|
|
1828
3679
|
},
|
|
1829
3680
|
onClientCancel: () => options.onNativePassthroughCancel?.(),
|
|
1830
3681
|
onDone: () => unregisterTurn(turnAc),
|
|
1831
|
-
},
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
3682
|
+
}, inlineEagerRewrite ? { rewriteBudget: translatorBudget } : undefined);
|
|
3683
|
+
// When selected, this relay closes response.completed even if upstream
|
|
3684
|
+
// keeps the connection alive. Marked Codex WS traffic, Windows
|
|
3685
|
+
// forced-rewrite traffic, and Darwin explicit eager traffic apply
|
|
3686
|
+
// client rewrites inline rather than via the tee()+JS-pull chain.
|
|
1835
3687
|
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1836
3688
|
return markEagerRelaySseResponse(
|
|
1837
3689
|
markNativePassthroughSseResponse(new Response(eagerBody, {
|
|
@@ -1840,7 +3692,7 @@ async function handleResponsesInner(
|
|
|
1840
3692
|
})),
|
|
1841
3693
|
);
|
|
1842
3694
|
}
|
|
1843
|
-
const [nativeBody, inspectBody] =
|
|
3695
|
+
const [nativeBody, inspectBody] = passthroughSseBody.tee();
|
|
1844
3696
|
const turnAc = new AbortController();
|
|
1845
3697
|
const clientGone = new AbortController();
|
|
1846
3698
|
linkAbortSignal(upstream, turnAc.signal);
|
|
@@ -1849,6 +3701,8 @@ async function handleResponsesInner(
|
|
|
1849
3701
|
clientGoneSignal: clientGone.signal,
|
|
1850
3702
|
drainBounds: { ms: 15_000, bytes: 32 * 1024 * 1024 },
|
|
1851
3703
|
upstream,
|
|
3704
|
+
pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled,
|
|
3705
|
+
onParsedPayload: noteInspectedPayload,
|
|
1852
3706
|
};
|
|
1853
3707
|
if (recordTerminalOutcomes) {
|
|
1854
3708
|
// A real terminal was parsed from the (teed) inspection stream — record it as the outcome
|
|
@@ -1863,7 +3717,7 @@ async function handleResponsesInner(
|
|
|
1863
3717
|
|| logCtx.terminalHttpStatus === 402
|
|
1864
3718
|
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
1865
3719
|
: undefined;
|
|
1866
|
-
if (quotaFailureMessage !== undefined) {
|
|
3720
|
+
if (!isFixedCodexAccount(authCtx) && quotaFailureMessage !== undefined) {
|
|
1867
3721
|
recordSubagentQuotaFailureForThreadSpawn(
|
|
1868
3722
|
req.headers,
|
|
1869
3723
|
subagentQuotaFailureModel,
|
|
@@ -1882,7 +3736,7 @@ async function handleResponsesInner(
|
|
|
1882
3736
|
() => unregisterTurn(turnAc),
|
|
1883
3737
|
logCtx,
|
|
1884
3738
|
() => options.onNativePassthroughCancel?.(),
|
|
1885
|
-
|
|
3739
|
+
rememberPassthroughResponseChecked,
|
|
1886
3740
|
options.onFirstOutput,
|
|
1887
3741
|
inspectionConsumerOptions,
|
|
1888
3742
|
);
|
|
@@ -1892,40 +3746,168 @@ async function handleResponsesInner(
|
|
|
1892
3746
|
logCtx,
|
|
1893
3747
|
turnAc.signal,
|
|
1894
3748
|
() => unregisterTurn(turnAc),
|
|
1895
|
-
|
|
3749
|
+
rememberPassthroughResponseChecked,
|
|
1896
3750
|
options.onFirstOutput,
|
|
1897
3751
|
inspectionConsumerOptions,
|
|
1898
3752
|
);
|
|
1899
3753
|
}
|
|
1900
3754
|
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1901
|
-
//
|
|
1902
|
-
// relay
|
|
1903
|
-
// mid-stream reset
|
|
1904
|
-
const rewrittenBody =
|
|
1905
|
-
?
|
|
3755
|
+
// Windows was handled by the eager terminal-aware branch above. Remaining
|
|
3756
|
+
// tee traffic can use the JS relay to close on a protocol terminal and to
|
|
3757
|
+
// convert a mid-stream reset into a clean response.failed event.
|
|
3758
|
+
const rewrittenBody = clientBlockRewrite !== undefined
|
|
3759
|
+
? relaySseWithBlockRewrite(nativeBody, clientBlockRewrite, translatorBudget)
|
|
1906
3760
|
: nativeBody;
|
|
1907
|
-
const clientBody =
|
|
1908
|
-
? nativeBody
|
|
1909
|
-
: relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason));
|
|
3761
|
+
const clientBody = relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason));
|
|
1910
3762
|
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
1911
3763
|
status: upstreamResponse.status,
|
|
1912
3764
|
headers,
|
|
1913
3765
|
}));
|
|
1914
3766
|
}
|
|
1915
3767
|
if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
1916
|
-
|
|
3768
|
+
// Bounded whole-body read: a non-streaming upstream JSON body is fully materialized
|
|
3769
|
+
// here (and again by the request-log finalizer and the WebSocket bridge's reframing),
|
|
3770
|
+
// so an unbounded .text() would let a hostile or stuck upstream grow proxy memory
|
|
3771
|
+
// without limit. This path is no longer rare — WebSocket turns for models whose
|
|
3772
|
+
// streaming terminal event is unreliable are deliberately answered with bounded JSON.
|
|
3773
|
+
// Oversize and stall deadlines both fail closed; a partial body is never parsed.
|
|
3774
|
+
const bounded = await readBoundedResponseBody(upstreamResponse, UPSTREAM_JSON_BODY_READ_OPTIONS);
|
|
3775
|
+
if (bounded.oversized) {
|
|
3776
|
+
return formatErrorResponse(502, "upstream_error", "upstream JSON response exceeded the safe body limit");
|
|
3777
|
+
}
|
|
3778
|
+
if (bounded.truncated) {
|
|
3779
|
+
return formatErrorResponse(502, "upstream_error", "upstream JSON response stalled before completing");
|
|
3780
|
+
}
|
|
3781
|
+
const text = bounded.text;
|
|
1917
3782
|
inspectResponseLogJson(logCtx, text);
|
|
1918
|
-
|
|
3783
|
+
const clientJson = (() => {
|
|
3784
|
+
const restoredNamespace = restoreRoutedNamespaceCallsInJson(
|
|
3785
|
+
restoreImageGenCallsInJson(text, imageGenCallAliases),
|
|
3786
|
+
routedNamespaceToolAliases,
|
|
3787
|
+
);
|
|
3788
|
+
const restored = restoreRoutedCustomCallsInJson(
|
|
3789
|
+
restoredNamespace,
|
|
3790
|
+
routedCustomToolNames,
|
|
3791
|
+
);
|
|
3792
|
+
const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
|
|
3793
|
+
restored,
|
|
3794
|
+
routedToolSearchNames,
|
|
3795
|
+
);
|
|
3796
|
+
const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)
|
|
3797
|
+
? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody)
|
|
3798
|
+
: restoredToolSearch;
|
|
3799
|
+
const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
|
|
3800
|
+
? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId)
|
|
3801
|
+
: backfillResponsesFieldsJson(repaired);
|
|
3802
|
+
// The bounded-JSON answer bypasses the SSE payload rewrite, so content-
|
|
3803
|
+
// channel reasoning needs the same normalization here for the plain
|
|
3804
|
+
// JSON answer and every reframed-SSE variant built from clientJson.
|
|
3805
|
+
return parsed.options.hideThinkingSummary !== true
|
|
3806
|
+
&& routeUsesContentChannelReasoning(route.provider, route.modelId)
|
|
3807
|
+
? rewriteReasoningSummaryInJsonString(modelRewritten)
|
|
3808
|
+
: modelRewritten;
|
|
3809
|
+
})();
|
|
3810
|
+
// #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and
|
|
3811
|
+
// the reframed-SSE branch below are built from this body, so one check covers them. This
|
|
3812
|
+
// runs BEFORE the continuation cache write below: a refused turn must not become state a
|
|
3813
|
+
// later `previous_response_id` replay can expand from.
|
|
3814
|
+
if (undeclaredToolGuardActive) {
|
|
3815
|
+
const undeclared = (() => {
|
|
3816
|
+
try {
|
|
3817
|
+
return undeclaredToolCallNameInResponse(JSON.parse(clientJson), declaredWireToolNames);
|
|
3818
|
+
} catch {
|
|
3819
|
+
return undefined;
|
|
3820
|
+
}
|
|
3821
|
+
})();
|
|
3822
|
+
if (undeclared !== undefined) {
|
|
3823
|
+
return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared));
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
commitReasoningReplayServingRoute();
|
|
3827
|
+
if (rememberPassthroughResponseChecked) {
|
|
1919
3828
|
try {
|
|
1920
|
-
|
|
3829
|
+
rememberPassthroughResponseChecked(
|
|
3830
|
+
JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown },
|
|
3831
|
+
);
|
|
1921
3832
|
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
1922
3833
|
}
|
|
1923
|
-
|
|
3834
|
+
// #875: the transport-neutral reliability policy forced a bounded JSON
|
|
3835
|
+
// upstream for a client that asked for SSE. Reframe the completed JSON
|
|
3836
|
+
// as the canonical terminal SSE sequence (created → output_item.done →
|
|
3837
|
+
// terminal → [DONE]) so Codex commits the turn instead of hanging on a
|
|
3838
|
+
// stream that never closes. Non-streaming clients keep the plain JSON.
|
|
3839
|
+
if (clientRequestedStream === true
|
|
3840
|
+
&& options.inboundTransport !== "websocket"
|
|
3841
|
+
&& providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false
|
|
3842
|
+
&& route.provider.adapter === "openai-responses") {
|
|
3843
|
+
let completed: Record<string, unknown> | undefined;
|
|
3844
|
+
try {
|
|
3845
|
+
const parsedCompleted = JSON.parse(clientJson) as unknown;
|
|
3846
|
+
if (!parsedCompleted || typeof parsedCompleted !== "object" || Array.isArray(parsedCompleted)) {
|
|
3847
|
+
throw new TypeError("bounded Responses JSON is not an object");
|
|
3848
|
+
}
|
|
3849
|
+
let candidate = parsedCompleted as Record<string, unknown>;
|
|
3850
|
+
// The bounded-JSON answer bypasses the SSE relay, so it also bypasses
|
|
3851
|
+
// the SSE item-id rewrite. Apply the same client-facing normalization
|
|
3852
|
+
// here or this policy would silently disable id repair for the very
|
|
3853
|
+
// providers that need it (raw record already happened above).
|
|
3854
|
+
if (hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)) {
|
|
3855
|
+
candidate = repairResponsesJsonItemIds(candidate, route.provider.responsesItemIdRepair!, translatorBudget);
|
|
3856
|
+
}
|
|
3857
|
+
completed = candidate;
|
|
3858
|
+
} catch {
|
|
3859
|
+
// Non-JSON despite content-type: fall through to the plain relay.
|
|
3860
|
+
}
|
|
3861
|
+
if (completed) {
|
|
3862
|
+
let stream: ReadableStream<Uint8Array>;
|
|
3863
|
+
try {
|
|
3864
|
+
stream = responsesJsonToSseStream(completed);
|
|
3865
|
+
} catch (error) {
|
|
3866
|
+
if (error instanceof RangeError) {
|
|
3867
|
+
return formatErrorResponse(
|
|
3868
|
+
502,
|
|
3869
|
+
"upstream_error",
|
|
3870
|
+
"upstream JSON response exceeded the synthesized SSE item limit",
|
|
3871
|
+
);
|
|
3872
|
+
}
|
|
3873
|
+
throw error;
|
|
3874
|
+
}
|
|
3875
|
+
const sseHeaders = sanitizePassthroughHeaders(headers);
|
|
3876
|
+
sseHeaders.set("content-type", "text/event-stream");
|
|
3877
|
+
sseHeaders.set("cache-control", "no-store");
|
|
3878
|
+
return new Response(stream, {
|
|
3879
|
+
status: upstreamResponse.status,
|
|
3880
|
+
statusText: upstreamResponse.statusText,
|
|
3881
|
+
headers: sseHeaders,
|
|
3882
|
+
});
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
// WS turns reframe this JSON into events in the bridge, which is the
|
|
3886
|
+
// other relay-free path — normalize ids so both bounded-JSON paths agree.
|
|
3887
|
+
const outboundJson = options.inboundTransport === "websocket"
|
|
3888
|
+
&& providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false
|
|
3889
|
+
&& hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)
|
|
3890
|
+
? (() => {
|
|
3891
|
+
try {
|
|
3892
|
+
return JSON.stringify(repairResponsesJsonItemIds(
|
|
3893
|
+
JSON.parse(clientJson) as Record<string, unknown>,
|
|
3894
|
+
route.provider.responsesItemIdRepair!,
|
|
3895
|
+
translatorBudget,
|
|
3896
|
+
));
|
|
3897
|
+
} catch {
|
|
3898
|
+
return clientJson;
|
|
3899
|
+
}
|
|
3900
|
+
})()
|
|
3901
|
+
: clientJson;
|
|
3902
|
+
return new Response(outboundJson, {
|
|
1924
3903
|
status: upstreamResponse.status,
|
|
1925
3904
|
statusText: upstreamResponse.statusText,
|
|
1926
3905
|
headers,
|
|
1927
3906
|
});
|
|
1928
3907
|
}
|
|
3908
|
+
// An unclassified passthrough body is relayed directly and has no bounded completion observer;
|
|
3909
|
+
// use the same non-error-status success boundary as SSE instead of retaining per-stream state.
|
|
3910
|
+
commitReasoningReplayServingRoute();
|
|
1929
3911
|
const body = relayWithAbort(upstreamResponse.body, upstream);
|
|
1930
3912
|
const turnAc = new AbortController();
|
|
1931
3913
|
const tracked = body ? trackStreamLifetime(body, turnAc, undefined, options.turnAdmissionLease) : null;
|
|
@@ -1933,6 +3915,12 @@ async function handleResponsesInner(
|
|
|
1933
3915
|
status: upstreamResponse.status,
|
|
1934
3916
|
headers,
|
|
1935
3917
|
});
|
|
3918
|
+
} finally {
|
|
3919
|
+
if (hostAdmissionLease) {
|
|
3920
|
+
releaseUpstreamHostAdmission(hostAdmissionLease);
|
|
3921
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
1936
3924
|
}
|
|
1937
3925
|
|
|
1938
3926
|
// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
|
|
@@ -2000,13 +3988,19 @@ async function handleResponsesInner(
|
|
|
2000
3988
|
&& (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
|
|
2001
3989
|
parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
|
|
2002
3990
|
}
|
|
3991
|
+
const imageProviderFetch = providerFetch(
|
|
3992
|
+
route.provider,
|
|
3993
|
+
options.codexWsRuntimeIdentity,
|
|
3994
|
+
{ providerName: route.providerName, modelId: route.modelId },
|
|
3995
|
+
);
|
|
2003
3996
|
const imgResponse = await runWithImageBridge({
|
|
2004
3997
|
parsed, adapter,
|
|
2005
3998
|
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
2006
3999
|
...(imgPlan ? { plan: imgPlan } : {}),
|
|
2007
4000
|
...(vidPlan ? { videoPlan: vidPlan } : {}),
|
|
2008
4001
|
forwardHeaders: selectedForwardHeaders,
|
|
2009
|
-
onAttemptSend: () =>
|
|
4002
|
+
onAttemptSend: (recovery?: AttemptRecoveryKind) =>
|
|
4003
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
|
|
2010
4004
|
abortSignal: options.abortSignal,
|
|
2011
4005
|
maxRounds: imgPlan && vidPlan
|
|
2012
4006
|
? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
|
|
@@ -2015,8 +4009,12 @@ async function handleResponsesInner(
|
|
|
2015
4009
|
: clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
|
|
2016
4010
|
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2017
4011
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
2018
|
-
|
|
2019
|
-
|
|
4012
|
+
waitForRequestSlot: imageProviderFetch.waitForPacing,
|
|
4013
|
+
fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch,
|
|
4014
|
+
onRequestBuilt: request => {
|
|
4015
|
+
recordAdapterReasoning(logCtx, request);
|
|
4016
|
+
recordAdapterTier(logCtx, request);
|
|
4017
|
+
},
|
|
2020
4018
|
...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
|
|
2021
4019
|
onUsage: usage => {
|
|
2022
4020
|
// Cursor may assign _cursorConversationId inside the image loop's first runTurn;
|
|
@@ -2040,20 +4038,30 @@ async function handleResponsesInner(
|
|
|
2040
4038
|
});
|
|
2041
4039
|
if (!rotated) return null;
|
|
2042
4040
|
route.provider = rotated;
|
|
2043
|
-
|
|
4041
|
+
const rotatedAdapter = resolveAdapter(
|
|
2044
4042
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2045
4043
|
config.cacheRetention,
|
|
2046
4044
|
);
|
|
4045
|
+
bindRouteReasoningReplayScope({
|
|
4046
|
+
parsed,
|
|
4047
|
+
providerName: route.providerName,
|
|
4048
|
+
provider: route.provider,
|
|
4049
|
+
adapterName: rotatedAdapter.name,
|
|
4050
|
+
});
|
|
4051
|
+
return rotatedAdapter;
|
|
2047
4052
|
},
|
|
4053
|
+
retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
|
|
2048
4054
|
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2049
4055
|
...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
|
|
2050
|
-
onCompletedResponse: (response, providerState) =>
|
|
4056
|
+
onCompletedResponse: (response, providerState) => {
|
|
4057
|
+
commitReasoningReplayServingRoute();
|
|
2051
4058
|
rememberResponseState(
|
|
2052
4059
|
parsed._rawBody,
|
|
2053
4060
|
response,
|
|
2054
4061
|
continuationStateForResponse(providerState),
|
|
2055
|
-
adapterNeedsForcedContinuation(adapter.name)
|
|
2056
|
-
)
|
|
4062
|
+
responseStateOptions(adapterNeedsForcedContinuation(adapter.name)),
|
|
4063
|
+
);
|
|
4064
|
+
},
|
|
2057
4065
|
});
|
|
2058
4066
|
if (imgResponse.body) {
|
|
2059
4067
|
const imgTurnAc = new AbortController();
|
|
@@ -2073,13 +4081,17 @@ async function handleResponsesInner(
|
|
|
2073
4081
|
// through web-search instead of being swallowed. runTurn adapters never enter this branch.
|
|
2074
4082
|
if (canRunWebSearch && wsPlan) {
|
|
2075
4083
|
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
2076
|
-
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
2077
4084
|
const wsResponse = await runWithWebSearch({
|
|
2078
4085
|
parsed, adapter,
|
|
2079
4086
|
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
2080
4087
|
backend: wsPlan.backend,
|
|
2081
4088
|
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
2082
4089
|
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
4090
|
+
xaiSidecar: wsPlan.xaiSidecar,
|
|
4091
|
+
geminiSidecar: wsPlan.geminiSidecar,
|
|
4092
|
+
xaiSearchOptions: wsPlan.xaiSearchOptions,
|
|
4093
|
+
// The exa key never rides the plan: read it from config at unpack time (L9).
|
|
4094
|
+
...(wsPlan.exaConfigured ? { exaApiKey: config.webSearchSidecar?.exaApiKey } : {}),
|
|
2083
4095
|
hostedTool: wsPlan.hostedTool,
|
|
2084
4096
|
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
2085
4097
|
settings: wsPlan.settings,
|
|
@@ -2087,7 +4099,12 @@ async function handleResponsesInner(
|
|
|
2087
4099
|
forceEmptyResponseId: true,
|
|
2088
4100
|
abortSignal: options.abortSignal,
|
|
2089
4101
|
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2090
|
-
onRequestBuilt: request =>
|
|
4102
|
+
onRequestBuilt: request => {
|
|
4103
|
+
recordAdapterReasoning(logCtx, request);
|
|
4104
|
+
recordAdapterTier(logCtx, request);
|
|
4105
|
+
},
|
|
4106
|
+
onAttemptSend: (recovery?: AttemptRecoveryKind) =>
|
|
4107
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery),
|
|
2091
4108
|
onUsage: usage => {
|
|
2092
4109
|
logCtx.usageFromBridge = true;
|
|
2093
4110
|
if (usage) {
|
|
@@ -2099,6 +4116,7 @@ async function handleResponsesInner(
|
|
|
2099
4116
|
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2100
4117
|
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
2101
4118
|
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
4119
|
+
streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
|
|
2102
4120
|
on429: retryAfter => {
|
|
2103
4121
|
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2104
4122
|
retryAfter,
|
|
@@ -2108,11 +4126,20 @@ async function handleResponsesInner(
|
|
|
2108
4126
|
});
|
|
2109
4127
|
if (!rotated) return null;
|
|
2110
4128
|
route.provider = rotated;
|
|
2111
|
-
|
|
4129
|
+
const rotatedAdapter = resolveAdapter(
|
|
2112
4130
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2113
4131
|
config.cacheRetention,
|
|
2114
4132
|
);
|
|
4133
|
+
bindRouteReasoningReplayScope({
|
|
4134
|
+
parsed,
|
|
4135
|
+
providerName: route.providerName,
|
|
4136
|
+
provider: route.provider,
|
|
4137
|
+
adapterName: rotatedAdapter.name,
|
|
4138
|
+
});
|
|
4139
|
+
return rotatedAdapter;
|
|
2115
4140
|
},
|
|
4141
|
+
retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
|
|
4142
|
+
onCompletedResponse: commitReasoningReplayServingRoute,
|
|
2116
4143
|
});
|
|
2117
4144
|
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
2118
4145
|
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
@@ -2126,36 +4153,107 @@ async function handleResponsesInner(
|
|
|
2126
4153
|
return wsResponse;
|
|
2127
4154
|
}
|
|
2128
4155
|
|
|
4156
|
+
// Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output
|
|
4157
|
+
// text and no tool call is a failure the client cannot see — it silently records the turn as
|
|
4158
|
+
// done. The guard holds pre-content adapter events, suppresses the terminal of an empty
|
|
4159
|
+
// turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is
|
|
4160
|
+
// empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a
|
|
4161
|
+
// disable-only emergency override. Compaction turns and combo attempts keep their own
|
|
4162
|
+
// machinery (the combo preflight already handles empty streams). Native Chat-to-Chat
|
|
4163
|
+
// requests return from handleChatCompletions before entering Responses core, so they are
|
|
4164
|
+
// intentionally outside this guard and retain their existing one-send wire behavior.
|
|
4165
|
+
const emptyCompletionGuardEnabled =
|
|
4166
|
+
emptyCompletionRetryEnabled(config)
|
|
4167
|
+
&& !options.comboAttempt
|
|
4168
|
+
&& !routedCompaction;
|
|
4169
|
+
|
|
2129
4170
|
if (adapter.runTurn) {
|
|
2130
4171
|
const runTurnAbort = new AbortController();
|
|
2131
|
-
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
4172
|
+
const cleanupRunTurnAbort = linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
2132
4173
|
const queue = createAdapterEventQueue({
|
|
2133
4174
|
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
2134
4175
|
});
|
|
2135
|
-
|
|
4176
|
+
// Initial admission must settle before the streaming Response commits HTTP 200.
|
|
4177
|
+
// Let the outer Responses facade preserve the local retryable-429 contract.
|
|
4178
|
+
try {
|
|
4179
|
+
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
|
|
4180
|
+
} catch (error) {
|
|
4181
|
+
cleanupRunTurnAbort();
|
|
4182
|
+
queue.close();
|
|
4183
|
+
throw error;
|
|
4184
|
+
}
|
|
4185
|
+
// One attempt of the runTurn transport, against an explicit queue. The
|
|
4186
|
+
// empty-completion guard re-invokes the IDENTICAL turn (same parsed request,
|
|
4187
|
+
// same forwarded headers, same abort signal) through a fresh queue, so the
|
|
4188
|
+
// attempt body must not capture the first queue. Each attempt consumes its
|
|
4189
|
+
// own provider pacing slot (#1584): retries are paced like first attempts.
|
|
4190
|
+
const runTurnAttempt = async (
|
|
4191
|
+
targetQueue: AdapterEventQueue,
|
|
4192
|
+
recovery?: AttemptRecoveryKind,
|
|
4193
|
+
pacingSlotAcquired = false,
|
|
4194
|
+
): Promise<void> => {
|
|
2136
4195
|
try {
|
|
2137
|
-
|
|
4196
|
+
if (!pacingSlotAcquired) {
|
|
4197
|
+
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
|
|
4198
|
+
}
|
|
4199
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery);
|
|
4200
|
+
const runTurnProviderFetch = providerFetch(
|
|
4201
|
+
route.provider,
|
|
4202
|
+
options.codexWsRuntimeIdentity,
|
|
4203
|
+
{
|
|
4204
|
+
providerName: route.providerName,
|
|
4205
|
+
modelId: route.modelId,
|
|
4206
|
+
// runTurnAttempt acquired this logical turn's first physical-request slot above.
|
|
4207
|
+
// Cursor HTTP/1.1 consumes it for RunSSE; every BidiAppend and redial then waits on
|
|
4208
|
+
// the same provider queue through this stateful wrapper.
|
|
4209
|
+
pacingSlotAcquired: true,
|
|
4210
|
+
},
|
|
4211
|
+
);
|
|
2138
4212
|
await adapter.runTurn?.(
|
|
2139
4213
|
parsed,
|
|
2140
|
-
{
|
|
2141
|
-
|
|
4214
|
+
{
|
|
4215
|
+
headers: selectedForwardHeaders,
|
|
4216
|
+
abortSignal: runTurnAbort.signal,
|
|
4217
|
+
translatorBudget,
|
|
4218
|
+
providerFetch: runTurnProviderFetch,
|
|
4219
|
+
},
|
|
4220
|
+
targetQueue.push,
|
|
2142
4221
|
);
|
|
2143
4222
|
} catch (err) {
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
4223
|
+
targetQueue.push(err instanceof RequestPacingQueueOverloadError
|
|
4224
|
+
? {
|
|
4225
|
+
type: "error",
|
|
4226
|
+
status: 429,
|
|
4227
|
+
errorType: "rate_limit_error",
|
|
4228
|
+
retryable: true,
|
|
4229
|
+
message: err.message,
|
|
4230
|
+
}
|
|
4231
|
+
: {
|
|
4232
|
+
type: "error",
|
|
4233
|
+
message: err instanceof Error ? err.message : String(err),
|
|
4234
|
+
});
|
|
2148
4235
|
} finally {
|
|
2149
4236
|
// Cursor assigns a stable conversation id inside runTurn on the first headerless
|
|
2150
4237
|
// turn; backfill so Logs can filter/total that opening request (#330 / #522).
|
|
2151
4238
|
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
2152
4239
|
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
2153
4240
|
}
|
|
2154
|
-
|
|
4241
|
+
targetQueue.close();
|
|
2155
4242
|
}
|
|
2156
4243
|
};
|
|
4244
|
+
const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true);
|
|
4245
|
+
// The empty-completion retry re-runs the turn against a fresh queue: the
|
|
4246
|
+
// first queue is closed once its attempt settles, and pushing into it after
|
|
4247
|
+
// close is a silent no-op.
|
|
4248
|
+
const runTurnRetrySource = (): AsyncIterable<AdapterEvent> => {
|
|
4249
|
+
const retryQueue = createAdapterEventQueue({
|
|
4250
|
+
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
4251
|
+
});
|
|
4252
|
+
void runTurnAttempt(retryQueue, "empty-completion");
|
|
4253
|
+
return retryQueue.stream();
|
|
4254
|
+
};
|
|
2157
4255
|
|
|
2158
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
4256
|
+
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2159
4257
|
if (parsed.stream) {
|
|
2160
4258
|
void runTurn();
|
|
2161
4259
|
let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
|
|
@@ -2169,19 +4267,33 @@ async function handleResponsesInner(
|
|
|
2169
4267
|
}
|
|
2170
4268
|
eventSource = preflight.stream;
|
|
2171
4269
|
}
|
|
4270
|
+
const guardedSource = emptyCompletionGuardEnabled
|
|
4271
|
+
? guardEmptyCompletionEventStream({
|
|
4272
|
+
firstEvents: eventSource,
|
|
4273
|
+
// Identical-turn retry: same parsed request, same headers, same
|
|
4274
|
+
// signal — run the adapter transport again against a fresh queue.
|
|
4275
|
+
continuation: runTurnRetrySource,
|
|
4276
|
+
})
|
|
4277
|
+
: eventSource;
|
|
2172
4278
|
const sseStream = bridgeToResponsesSSE(
|
|
2173
|
-
|
|
4279
|
+
guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
2174
4280
|
() => {
|
|
2175
4281
|
runTurnAbort.abort();
|
|
2176
4282
|
queue.close();
|
|
2177
4283
|
}, 2_000,
|
|
2178
4284
|
{
|
|
2179
4285
|
translatorBudget,
|
|
4286
|
+
replayCacheScope: parsed._reasoningReplayScope,
|
|
2180
4287
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
2181
4288
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
2182
4289
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
4290
|
+
declaredToolNames,
|
|
4291
|
+
toolParameterSchemas,
|
|
2183
4292
|
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2184
4293
|
...(routedCompaction ? { compaction: true } : {}),
|
|
4294
|
+
// grok-build's strict decoder dies on the typed response.heartbeat frame; its
|
|
4295
|
+
// eventsource layer tolerates comment keep-alives. Codex needs the opposite.
|
|
4296
|
+
...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}),
|
|
2185
4297
|
onUsage: usage => {
|
|
2186
4298
|
// Raw adapter usage, pre wire-normalization: the bridged SSE now always carries
|
|
2187
4299
|
// zero-default detail objects, so provenance must come from here (cache_detail_missing).
|
|
@@ -2191,15 +4303,17 @@ async function handleResponsesInner(
|
|
|
2191
4303
|
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2192
4304
|
}
|
|
2193
4305
|
},
|
|
2194
|
-
|
|
2195
|
-
|
|
4306
|
+
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
|
|
4307
|
+
commitReasoningReplayServingRoute();
|
|
4308
|
+
if (!routedCompaction) {
|
|
2196
4309
|
rememberResponseState(
|
|
2197
4310
|
parsed._rawBody,
|
|
2198
4311
|
response,
|
|
2199
4312
|
continuationStateForResponse(providerState),
|
|
2200
|
-
adapterNeedsForcedContinuation(adapter.name)
|
|
2201
|
-
)
|
|
2202
|
-
|
|
4313
|
+
responseStateOptions(adapterNeedsForcedContinuation(adapter.name)),
|
|
4314
|
+
);
|
|
4315
|
+
}
|
|
4316
|
+
},
|
|
2203
4317
|
},
|
|
2204
4318
|
);
|
|
2205
4319
|
const bridgeTurnAc = new AbortController();
|
|
@@ -2210,7 +4324,17 @@ async function handleResponsesInner(
|
|
|
2210
4324
|
}
|
|
2211
4325
|
|
|
2212
4326
|
await runTurn();
|
|
2213
|
-
const
|
|
4327
|
+
const firstAttemptEvents = await queue.collect();
|
|
4328
|
+
let events: AdapterEvent[];
|
|
4329
|
+
if (emptyCompletionGuardEnabled) {
|
|
4330
|
+
events = [];
|
|
4331
|
+
for await (const event of guardEmptyCompletionEventStream({
|
|
4332
|
+
firstEvents: (async function* () { yield* firstAttemptEvents; })(),
|
|
4333
|
+
continuation: runTurnRetrySource,
|
|
4334
|
+
})) events.push(event);
|
|
4335
|
+
} else {
|
|
4336
|
+
events = firstAttemptEvents;
|
|
4337
|
+
}
|
|
2214
4338
|
if (options.comboAttempt) {
|
|
2215
4339
|
const firstMeaningful = events.find(event => event.type !== "heartbeat");
|
|
2216
4340
|
if (!firstMeaningful || firstMeaningful.type === "error") {
|
|
@@ -2221,10 +4345,13 @@ async function handleResponsesInner(
|
|
|
2221
4345
|
}
|
|
2222
4346
|
}
|
|
2223
4347
|
let providerState: OcxProviderContinuationState | undefined;
|
|
2224
|
-
const json = buildResponseJSON(events, parsed.modelId, {
|
|
4348
|
+
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
|
|
2225
4349
|
translatorBudget,
|
|
4350
|
+
replayCacheScope: parsed._reasoningReplayScope,
|
|
2226
4351
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2227
4352
|
toolNsMap,
|
|
4353
|
+
declaredToolNames,
|
|
4354
|
+
toolParameterSchemas,
|
|
2228
4355
|
freeformToolNames,
|
|
2229
4356
|
toolSearchToolNames,
|
|
2230
4357
|
...(routedCompaction ? { compaction: true } : {}),
|
|
@@ -2242,43 +4369,105 @@ async function handleResponsesInner(
|
|
|
2242
4369
|
parsed._rawBody,
|
|
2243
4370
|
json,
|
|
2244
4371
|
continuationStateForResponse(providerState),
|
|
2245
|
-
adapterNeedsForcedContinuation(adapter.name)
|
|
4372
|
+
responseStateOptions(adapterNeedsForcedContinuation(adapter.name)),
|
|
2246
4373
|
);
|
|
2247
4374
|
}
|
|
4375
|
+
// #1926 gap 2: the buffered path queued its signature persists inside
|
|
4376
|
+
// buildResponseJSON; bound the durability window before the JSON becomes
|
|
4377
|
+
// externally visible.
|
|
4378
|
+
await awaitThoughtSignatureDurability();
|
|
4379
|
+
if (adapterResponseReachedServingTerminal(events, json)) {
|
|
4380
|
+
commitReasoningReplayServingRoute();
|
|
4381
|
+
}
|
|
2248
4382
|
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
2249
4383
|
}
|
|
2250
4384
|
|
|
2251
4385
|
const upstream = new AbortController();
|
|
2252
4386
|
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
2253
4387
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
4388
|
+
// Bridge stall budget (seconds of silence before upstream_stall_timeout); the retry backoff
|
|
4389
|
+
// heartbeat interval is derived from it so the watchdog is always fed during deliberate waits.
|
|
4390
|
+
const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0
|
|
4391
|
+
? Math.floor(config.stallTimeoutSec * 1000)
|
|
4392
|
+
: 300_000;
|
|
2254
4393
|
let activeAdapter = adapter;
|
|
2255
4394
|
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
4395
|
+
// One immutable, body-safe outbound request per same-target sequence (URL, serialized body,
|
|
4396
|
+
// auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the
|
|
4397
|
+
// builder runs again only after a key/account/adapter rotation, an oauth refresh, or an
|
|
4398
|
+
// image-tier bias change (transportToken bump). `body` is always a serialized string, so
|
|
4399
|
+
// reuse is safe, and releaseBodyObservation is idempotent per build.
|
|
4400
|
+
let initialRequest: AdapterRequest | undefined;
|
|
4401
|
+
let inputTokenEstimate: number | undefined;
|
|
4402
|
+
try {
|
|
4403
|
+
initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
4404
|
+
refreshRoutedNamespaceToolAliases(initialRequest);
|
|
4405
|
+
recordAdapterReasoning(logCtx, initialRequest);
|
|
4406
|
+
recordAdapterTier(logCtx, initialRequest);
|
|
4407
|
+
inputTokenEstimate = typeof initialRequest.usageLog?.inputTokens === "number"
|
|
4408
|
+
? initialRequest.usageLog.inputTokens
|
|
4409
|
+
: undefined;
|
|
4410
|
+
if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate;
|
|
4411
|
+
} catch (err) {
|
|
4412
|
+
// A throwing buildRequest never returned a request; if a post-build step threw, release
|
|
4413
|
+
// the serialized-body observation (idempotent) so the translator budget is not leaked.
|
|
4414
|
+
// The build runs after linkAbortSignal, so a failure must also tear the link down and
|
|
4415
|
+
// abort the upstream controller instead of escaping handleResponses unmapped.
|
|
4416
|
+
initialRequest?.releaseBodyObservation?.();
|
|
4417
|
+
cleanupUpstreamAbort();
|
|
4418
|
+
upstream.abort();
|
|
4419
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
4420
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4421
|
+
return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
|
|
4422
|
+
}
|
|
4423
|
+
// The catch path above always returns, so the request is definitely assigned here.
|
|
4424
|
+
// Capture it in a const so the fetch callbacks read a narrowed, immutable value
|
|
4425
|
+
// (TypeScript drops narrowing for a `let` captured by a nested function).
|
|
4426
|
+
const builtInitialRequest = initialRequest;
|
|
4427
|
+
let sameTargetRequest: AdapterRequest | undefined = builtInitialRequest;
|
|
4428
|
+
let sameTargetParsed: OcxParsedRequest | undefined = parsed;
|
|
4429
|
+
let sameTargetToken = 0;
|
|
4430
|
+
let transportToken = 0;
|
|
4431
|
+
/**
|
|
4432
|
+
* Invalidate the same-target request cache. Every credential/adapter/parsed mutation MUST
|
|
4433
|
+
* go through here: the cache keys on `parsed` REFERENCE identity, so an in-place mutation
|
|
4434
|
+
* is invisible to it and a missed bump would replay a request built with a stale key.
|
|
4435
|
+
*/
|
|
4436
|
+
const invalidateSameTargetRequest = (): void => { transportToken += 1; };
|
|
2262
4437
|
let upstreamResponse: Response;
|
|
2263
4438
|
try {
|
|
2264
4439
|
if (activeAdapter.fetchResponse) {
|
|
2265
4440
|
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
|
|
2266
|
-
|
|
4441
|
+
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
|
|
4442
|
+
upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, {
|
|
2267
4443
|
abortSignal: upstream.signal,
|
|
2268
4444
|
timeoutMs: connectMs,
|
|
2269
4445
|
stream: parsed.stream,
|
|
4446
|
+
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
4447
|
+
providerName: route.providerName,
|
|
4448
|
+
modelId: route.modelId,
|
|
4449
|
+
}),
|
|
2270
4450
|
});
|
|
2271
4451
|
} else {
|
|
2272
|
-
|
|
4452
|
+
// #1851 scope guard: transient-5xx retry on this generic adapter path is opt-in for
|
|
4453
|
+
// direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other
|
|
4454
|
+
// adapters keep reset-only retry so combo failover still hops on the first 5xx
|
|
4455
|
+
// instead of burning ~1.2s of same-target retries per hop.
|
|
4456
|
+
const fetchWithRetryPolicy = route.provider.adapter === "google" ? fetchWithTransientRetry : fetchWithResetRetry;
|
|
4457
|
+
upstreamResponse = await fetchWithRetryPolicy(
|
|
2273
4458
|
recovery => {
|
|
2274
4459
|
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
|
|
2275
|
-
return fetchWithHeaderTimeout(
|
|
2276
|
-
method:
|
|
2277
|
-
headers:
|
|
2278
|
-
body:
|
|
2279
|
-
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
4460
|
+
return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
|
|
4461
|
+
method: builtInitialRequest.method,
|
|
4462
|
+
headers: builtInitialRequest.headers,
|
|
4463
|
+
body: builtInitialRequest.body,
|
|
4464
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
4465
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
4466
|
+
providerName: route.providerName,
|
|
4467
|
+
modelId: route.modelId,
|
|
4468
|
+
}));
|
|
2280
4469
|
},
|
|
2281
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(
|
|
4470
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) },
|
|
2282
4471
|
);
|
|
2283
4472
|
}
|
|
2284
4473
|
} catch (err) {
|
|
@@ -2288,41 +4477,92 @@ async function handleResponsesInner(
|
|
|
2288
4477
|
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2289
4478
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
2290
4479
|
} finally {
|
|
2291
|
-
|
|
4480
|
+
builtInitialRequest.releaseBodyObservation?.();
|
|
2292
4481
|
}
|
|
2293
4482
|
|
|
4483
|
+
// Same-target 429 retry budget is per REQUEST: it lives OUTSIDE the recovery loop (so a 413/401
|
|
4484
|
+
// replay that comes back 429 cannot silently re-arm a fresh budget) and is SHARED with the
|
|
4485
|
+
// terminal-guard continuation below, so the main loop + one continuation can never exceed
|
|
4486
|
+
// `attempts` same-key replays in total (bounded per request).
|
|
4487
|
+
const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
|
|
4488
|
+
let rateLimitRetries = 0;
|
|
4489
|
+
// Shared with the terminal-guard continuation below: an image-tier reduction that let the
|
|
4490
|
+
// main request clear a 413 must not be forgotten on the very next continuation build.
|
|
4491
|
+
let imageTierBias = 0;
|
|
2294
4492
|
if (!upstreamResponse.ok) {
|
|
2295
|
-
// Recovery loop: multi-key 429 failover + at most ONE
|
|
4493
|
+
// Recovery loop: multi-key 429 failover + at most ONE opaque-state rebuild and ONE
|
|
4494
|
+
// anthropic 413 tightened retry
|
|
2296
4495
|
// (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
|
|
2297
4496
|
// both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
|
|
2298
4497
|
// adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a
|
|
2299
4498
|
// 413→429 rotation cannot silently undo the tightening.
|
|
2300
|
-
let imageTierBias = 0;
|
|
2301
4499
|
let imageRetryAttempted = false;
|
|
4500
|
+
const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false };
|
|
2302
4501
|
let oauth401ReplayAttempted = false;
|
|
4502
|
+
/**
|
|
4503
|
+
* Rebuild the request from the current parsed input (and any image-tier bias) and refetch
|
|
4504
|
+
* it once, tagging the attempt with the given recovery kind. Rebuilds are deterministic
|
|
4505
|
+
* for the same parsed request, so same-target replays stay byte-identical.
|
|
4506
|
+
*/
|
|
2303
4507
|
const rebuildAndRefetch = async (
|
|
2304
4508
|
recovery: AttemptRecoveryKind,
|
|
2305
4509
|
): Promise<Response | { failed: Response }> => {
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
}
|
|
2311
|
-
|
|
4510
|
+
let retryRequest: AdapterRequest;
|
|
4511
|
+
if (sameTargetRequest !== undefined && sameTargetParsed === parsed && sameTargetToken === transportToken) {
|
|
4512
|
+
// Same target (key/adapter/parsed/tier unchanged): replay the exact cached request.
|
|
4513
|
+
retryRequest = sameTargetRequest;
|
|
4514
|
+
} else {
|
|
4515
|
+
try {
|
|
4516
|
+
retryRequest = await activeAdapter.buildRequest(parsed, {
|
|
4517
|
+
headers: selectedForwardHeaders,
|
|
4518
|
+
translatorBudget,
|
|
4519
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
4520
|
+
});
|
|
4521
|
+
recordAdapterReasoning(logCtx, retryRequest);
|
|
4522
|
+
recordAdapterTier(logCtx, retryRequest);
|
|
4523
|
+
} catch (err) {
|
|
4524
|
+
// A rotated/rebuilt adapter build failure is a request-shaping error, not an
|
|
4525
|
+
// upstream connect failure: tear the abort link down and map it as 400 (no 413
|
|
4526
|
+
// translator-budget mapping here — that stays with parseRequest/buildToolBridgeMaps).
|
|
4527
|
+
cleanupUpstreamAbort();
|
|
4528
|
+
upstream.abort();
|
|
4529
|
+
if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() };
|
|
4530
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4531
|
+
return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) };
|
|
4532
|
+
}
|
|
4533
|
+
sameTargetRequest = retryRequest;
|
|
4534
|
+
sameTargetParsed = parsed;
|
|
4535
|
+
sameTargetToken = transportToken;
|
|
4536
|
+
}
|
|
4537
|
+
refreshRoutedNamespaceToolAliases(retryRequest);
|
|
2312
4538
|
const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number"
|
|
2313
4539
|
? retryRequest.usageLog.inputTokens
|
|
2314
4540
|
: undefined;
|
|
2315
4541
|
if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
|
|
2316
4542
|
logCtx.providerAdapter = activeAdapter.name;
|
|
2317
|
-
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
4543
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
|
|
2318
4544
|
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
|
|
2319
4545
|
try {
|
|
2320
4546
|
try {
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
4547
|
+
if (activeAdapter.fetchResponse) {
|
|
4548
|
+
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
|
|
4549
|
+
return await activeAdapter.fetchResponse(retryRequest, {
|
|
4550
|
+
abortSignal: upstream.signal,
|
|
4551
|
+
timeoutMs: connectMs,
|
|
4552
|
+
stream: parsed.stream,
|
|
4553
|
+
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
4554
|
+
providerName: route.providerName,
|
|
4555
|
+
modelId: route.modelId,
|
|
4556
|
+
}),
|
|
4557
|
+
});
|
|
4558
|
+
}
|
|
4559
|
+
return await fetchWithHeaderTimeout(retryRequest.url, {
|
|
4560
|
+
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
4561
|
+
}, upstream.signal, connectMs, parsed.stream,
|
|
4562
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
4563
|
+
providerName: route.providerName,
|
|
4564
|
+
modelId: route.modelId,
|
|
4565
|
+
}));
|
|
2326
4566
|
} finally {
|
|
2327
4567
|
retryRequest.releaseBodyObservation?.();
|
|
2328
4568
|
}
|
|
@@ -2336,6 +4576,7 @@ async function handleResponsesInner(
|
|
|
2336
4576
|
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
2337
4577
|
}
|
|
2338
4578
|
};
|
|
4579
|
+
// Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
|
|
2339
4580
|
recovery: for (;;) {
|
|
2340
4581
|
if (
|
|
2341
4582
|
upstreamResponse.status === 401
|
|
@@ -2350,9 +4591,13 @@ async function handleResponsesInner(
|
|
|
2350
4591
|
refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
|
|
2351
4592
|
} catch (err) {
|
|
2352
4593
|
cleanupUpstreamAbort();
|
|
2353
|
-
return formatErrorResponse(401, "authentication_error",
|
|
4594
|
+
return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
|
|
2354
4595
|
}
|
|
2355
4596
|
sentOAuthSnapshot = refreshed;
|
|
4597
|
+
replayOAuthCredentialSnapshot = {
|
|
4598
|
+
accountId: refreshed.accountId,
|
|
4599
|
+
generation: refreshed.generation,
|
|
4600
|
+
};
|
|
2356
4601
|
if (route.providerName === "kiro") {
|
|
2357
4602
|
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
2358
4603
|
}
|
|
@@ -2363,16 +4608,63 @@ async function handleResponsesInner(
|
|
|
2363
4608
|
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
2364
4609
|
);
|
|
2365
4610
|
route.provider = refreshedProvider;
|
|
4611
|
+
invalidateSameTargetRequest();
|
|
2366
4612
|
activeAdapter = resolveAdapter(
|
|
2367
4613
|
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
|
|
2368
4614
|
config.cacheRetention,
|
|
2369
4615
|
);
|
|
4616
|
+
bindRouteReasoningReplayScope({
|
|
4617
|
+
parsed,
|
|
4618
|
+
providerName: route.providerName,
|
|
4619
|
+
provider: refreshedProvider,
|
|
4620
|
+
adapterName: activeAdapter.name,
|
|
4621
|
+
oauthCredentialSnapshot: replayOAuthCredentialSnapshot,
|
|
4622
|
+
});
|
|
2370
4623
|
const result = await rebuildAndRefetch("oauth-401");
|
|
2371
4624
|
if ("failed" in result) return result.failed;
|
|
2372
4625
|
upstreamResponse = result;
|
|
2373
4626
|
continue recovery;
|
|
2374
4627
|
}
|
|
2375
4628
|
|
|
4629
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
|
|
4630
|
+
// 429 itself (it retries 5xx only), and single-key pools cannot use the failover below,
|
|
4631
|
+
// so wait (Retry-After or the fixed interval) and replay the IDENTICAL request on the
|
|
4632
|
+
// same key first. Pre-stream only: a 429 arrives before any bytes are relayed, so the
|
|
4633
|
+
// replay is lossless. Runs before key failover so "primary-first" setups keep the same
|
|
4634
|
+
// key on rate-limit blips; only after the attempts are exhausted does failover run.
|
|
4635
|
+
while (
|
|
4636
|
+
upstreamResponse.status === 429
|
|
4637
|
+
&& rateLimitPolicy !== null
|
|
4638
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
4639
|
+
) {
|
|
4640
|
+
rateLimitRetries += 1;
|
|
4641
|
+
// Release unread body + deliberate wait via the shared same-target helper.
|
|
4642
|
+
const retryAfterHeader = upstreamResponse.headers.get("retry-after");
|
|
4643
|
+
try {
|
|
4644
|
+
for await (const _ of prepareSameTarget429Wait({
|
|
4645
|
+
body: upstreamResponse.body,
|
|
4646
|
+
signal: options.abortSignal,
|
|
4647
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
4648
|
+
})) {
|
|
4649
|
+
// pre-stream: no stall watchdog to feed
|
|
4650
|
+
}
|
|
4651
|
+
} catch {
|
|
4652
|
+
cleanupUpstreamAbort();
|
|
4653
|
+
upstream.abort();
|
|
4654
|
+
return clientCancelledResponse();
|
|
4655
|
+
}
|
|
4656
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
4657
|
+
// replay so an adapter never starts work for a request the client already abandoned.
|
|
4658
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
4659
|
+
cleanupUpstreamAbort();
|
|
4660
|
+
upstream.abort();
|
|
4661
|
+
return clientCancelledResponse();
|
|
4662
|
+
}
|
|
4663
|
+
const result = await rebuildAndRefetch("rate-limit-429");
|
|
4664
|
+
if ("failed" in result) return result.failed;
|
|
4665
|
+
upstreamResponse = result;
|
|
4666
|
+
}
|
|
4667
|
+
|
|
2376
4668
|
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
2377
4669
|
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
2378
4670
|
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
@@ -2388,10 +4680,17 @@ async function handleResponsesInner(
|
|
|
2388
4680
|
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
2389
4681
|
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2390
4682
|
route.provider = rotated;
|
|
4683
|
+
invalidateSameTargetRequest();
|
|
2391
4684
|
activeAdapter = resolveAdapter(
|
|
2392
4685
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2393
4686
|
config.cacheRetention,
|
|
2394
4687
|
);
|
|
4688
|
+
bindRouteReasoningReplayScope({
|
|
4689
|
+
parsed,
|
|
4690
|
+
providerName: route.providerName,
|
|
4691
|
+
provider: route.provider,
|
|
4692
|
+
adapterName: activeAdapter.name,
|
|
4693
|
+
});
|
|
2395
4694
|
const result = await rebuildAndRefetch("key-429");
|
|
2396
4695
|
if ("failed" in result) return result.failed;
|
|
2397
4696
|
upstreamResponse = result;
|
|
@@ -2418,13 +4717,14 @@ async function handleResponsesInner(
|
|
|
2418
4717
|
anthropicPoolAccountId = nextAccountId;
|
|
2419
4718
|
anthropicPoolFailovers += 1;
|
|
2420
4719
|
route.provider = { ...route.provider, apiKey: accessToken };
|
|
4720
|
+
invalidateSameTargetRequest();
|
|
2421
4721
|
promoteAnthropicActiveAccount(nextAccountId);
|
|
2422
4722
|
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2423
4723
|
activeAdapter = resolveAdapter(
|
|
2424
4724
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2425
4725
|
config.cacheRetention,
|
|
2426
4726
|
);
|
|
2427
|
-
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
4727
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
|
|
2428
4728
|
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
2429
4729
|
if ("failed" in result) return result.failed;
|
|
2430
4730
|
upstreamResponse = result;
|
|
@@ -2432,6 +4732,27 @@ async function handleResponsesInner(
|
|
|
2432
4732
|
break;
|
|
2433
4733
|
}
|
|
2434
4734
|
}
|
|
4735
|
+
// Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
|
|
4736
|
+
// or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is
|
|
4737
|
+
// the missing authoritative signal. Rebuild once through the same sanitation path used by a
|
|
4738
|
+
// known route switch; invalidating is mandatory because `parsed` mutates in place and the
|
|
4739
|
+
// same-target cache would otherwise replay the rejected bytes verbatim.
|
|
4740
|
+
const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({
|
|
4741
|
+
response: upstreamResponse,
|
|
4742
|
+
outboundBody: sameTargetRequest?.body,
|
|
4743
|
+
adapterName: activeAdapter.name,
|
|
4744
|
+
parsed,
|
|
4745
|
+
guard: opaqueBlobRecoveryGuard,
|
|
4746
|
+
signal: upstream.signal,
|
|
4747
|
+
}, recovery => {
|
|
4748
|
+
invalidateSameTargetRequest();
|
|
4749
|
+
return rebuildAndRefetch(recovery);
|
|
4750
|
+
});
|
|
4751
|
+
if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response;
|
|
4752
|
+
if (opaqueBlobRecovery.kind === "recovered") {
|
|
4753
|
+
upstreamResponse = opaqueBlobRecovery.response;
|
|
4754
|
+
continue recovery;
|
|
4755
|
+
}
|
|
2435
4756
|
// Anthropic 413 request_too_large: rebuild once with every image one tier lower
|
|
2436
4757
|
// (spiral guard: single attempt). The biased response re-enters the 429 check above.
|
|
2437
4758
|
if (shouldAttemptImageTierRetry({
|
|
@@ -2442,6 +4763,7 @@ async function handleResponsesInner(
|
|
|
2442
4763
|
})) {
|
|
2443
4764
|
imageRetryAttempted = true;
|
|
2444
4765
|
imageTierBias = 1;
|
|
4766
|
+
invalidateSameTargetRequest();
|
|
2445
4767
|
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2446
4768
|
const result = await rebuildAndRefetch("image-413");
|
|
2447
4769
|
if ("failed" in result) return result.failed;
|
|
@@ -2452,29 +4774,56 @@ async function handleResponsesInner(
|
|
|
2452
4774
|
}
|
|
2453
4775
|
if (!upstreamResponse.ok) {
|
|
2454
4776
|
if (options.comboAttempt) {
|
|
4777
|
+
// No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads
|
|
4778
|
+
// `response.body` itself with the abort signal threaded through, and the combo
|
|
4779
|
+
// contract is that this body's getter is touched exactly once. A guard here would be
|
|
4780
|
+
// a second `.body` access for no gain, since the bounded reader owns settlement.
|
|
2455
4781
|
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
|
|
2456
4782
|
.finally(cleanupUpstreamAbort);
|
|
2457
4783
|
options.onConsumedComboFailure?.(failure);
|
|
2458
4784
|
return failure.response;
|
|
2459
4785
|
}
|
|
4786
|
+
// The plain error path has no bounded reader of its own: `.text()` attaches the reader
|
|
4787
|
+
// only when it runs, so an abort landing between fetch resolution and that call orphans
|
|
4788
|
+
// Bun's internal read rejection — the uncatchable teardown `cancelBodyOnAbort` absorbs
|
|
4789
|
+
// (src/lib/abort.ts). Found while investigating #1419; not a fix for the native trap.
|
|
4790
|
+
const detachErrorBodyGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
2460
4791
|
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
4792
|
+
detachErrorBodyGuard();
|
|
2461
4793
|
cleanupUpstreamAbort();
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
4794
|
+
if (!isFixedCodexAccount(authCtx)) {
|
|
4795
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
4796
|
+
req.headers,
|
|
4797
|
+
subagentQuotaFailureModel,
|
|
4798
|
+
upstreamResponse.status === 429 || upstreamResponse.status === 402
|
|
4799
|
+
? upstreamResponse.status
|
|
4800
|
+
: `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
4801
|
+
config,
|
|
4802
|
+
subagentFallbackAccountId,
|
|
4803
|
+
);
|
|
4804
|
+
}
|
|
2471
4805
|
// Upstreams occasionally echo request details in error bodies — scrub token-shaped
|
|
2472
4806
|
// material before it reaches the client-facing error surface.
|
|
2473
|
-
const
|
|
4807
|
+
const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
|
|
4808
|
+
const message = enrichOpenCodeZenRateLimitMessage(
|
|
4809
|
+
`Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
4810
|
+
{
|
|
4811
|
+
status: upstreamResponse.status,
|
|
4812
|
+
providerName: route.providerName,
|
|
4813
|
+
baseUrl: route.provider.baseUrl,
|
|
4814
|
+
adapter: route.provider.adapter,
|
|
4815
|
+
authMode: route.provider.authMode,
|
|
4816
|
+
hasApiKey: Boolean(route.provider.apiKey?.trim()),
|
|
4817
|
+
upstreamRetryAfter,
|
|
4818
|
+
// This recovery path is the HTTP Responses wire; custom runTurn transports
|
|
4819
|
+
// never reach enrichOpenCodeZenRateLimitMessage here.
|
|
4820
|
+
supportsHttpSameKeyRetry: true,
|
|
4821
|
+
},
|
|
4822
|
+
);
|
|
2474
4823
|
const retryAfter = resolveClientRetryAfter({
|
|
2475
4824
|
status: upstreamResponse.status,
|
|
2476
4825
|
message,
|
|
2477
|
-
upstreamRetryAfter
|
|
4826
|
+
upstreamRetryAfter,
|
|
2478
4827
|
});
|
|
2479
4828
|
return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
|
|
2480
4829
|
...(retryAfter !== undefined ? { retryAfter } : {}),
|
|
@@ -2484,64 +4833,169 @@ async function handleResponsesInner(
|
|
|
2484
4833
|
|
|
2485
4834
|
cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
2486
4835
|
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
//
|
|
2490
|
-
//
|
|
2491
|
-
const terminalGuardEnabled = activeAdapter.name === "anthropic"
|
|
2492
|
-
|
|
2493
|
-
|
|
4836
|
+
// One bounded internal continuation re-ask for clean end_turn turns that announced an edit
|
|
4837
|
+
// without emitting a tool call. Anthropic gets this by default; openai-chat providers opt in
|
|
4838
|
+
// per-provider via `terminalContinuationGuard` (the heuristic was tuned on Anthropic turns,
|
|
4839
|
+
// so it stays off for the shared openai-chat adapter unless a provider enables it).
|
|
4840
|
+
const terminalGuardEnabled = (activeAdapter.name === "anthropic"
|
|
4841
|
+
|| (activeAdapter.name === "openai-chat" && route.provider.terminalContinuationGuard === true))
|
|
4842
|
+
&& !options.comboAttempt && !routedCompaction;
|
|
4843
|
+
/**
|
|
4844
|
+
* One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the
|
|
4845
|
+
* continuation on a 429 with the same-key retry budget (hoisted per request), then falls
|
|
4846
|
+
* back to key/account failover; a failure becomes an in-stream adapter error so the client
|
|
4847
|
+
* never sees a second hidden HTTP response or an unbounded retry loop.
|
|
4848
|
+
*/
|
|
4849
|
+
const fetchTerminalGuardContinuation = async function* (
|
|
4850
|
+
nextParsed: OcxParsedRequest,
|
|
4851
|
+
initialRecoveryKind?: AttemptRecoveryKind,
|
|
4852
|
+
): AsyncGenerator<AdapterEvent> {
|
|
2494
4853
|
let response: Response | undefined;
|
|
4854
|
+
// One-shot recovery label for the next top-of-loop continuation send after a failover rotation.
|
|
4855
|
+
let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined = initialRecoveryKind;
|
|
4856
|
+
/**
|
|
4857
|
+
* Build and fetch one terminal-guard continuation. `recoveryKind` tags same-target and
|
|
4858
|
+
* failover sends (`empty-completion`, `rate-limit-429`, `key-429`,
|
|
4859
|
+
* `anthropic-oauth-429`, `image-413`); the
|
|
4860
|
+
* adapter rebuild is deterministic for the same parsed request (tests assert byte-identical
|
|
4861
|
+
* replays).
|
|
4862
|
+
*/
|
|
4863
|
+
const fetchContinuation = async (recoveryKind?: AttemptRecoveryKind): Promise<Response> => {
|
|
4864
|
+
let continuationRequest: AdapterRequest | undefined;
|
|
4865
|
+
if (sameTargetRequest !== undefined && sameTargetParsed === nextParsed && sameTargetToken === transportToken) {
|
|
4866
|
+
// Same target (key/adapter/parsed/tier unchanged): replay the exact cached request.
|
|
4867
|
+
continuationRequest = sameTargetRequest;
|
|
4868
|
+
} else {
|
|
4869
|
+
try {
|
|
4870
|
+
continuationRequest = await activeAdapter.buildRequest(nextParsed, {
|
|
4871
|
+
headers: selectedForwardHeaders,
|
|
4872
|
+
translatorBudget,
|
|
4873
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
4874
|
+
});
|
|
4875
|
+
recordAdapterReasoning(logCtx, continuationRequest);
|
|
4876
|
+
recordAdapterTier(logCtx, continuationRequest);
|
|
4877
|
+
} catch (err) {
|
|
4878
|
+
// The main body is already streaming, so there is no HTTP error surface: release
|
|
4879
|
+
// any partial body observation and surface the failure as an in-stream error via
|
|
4880
|
+
// the outer catch (no upstream.abort() — that would kill the live body stream).
|
|
4881
|
+
continuationRequest?.releaseBodyObservation?.();
|
|
4882
|
+
throw err;
|
|
4883
|
+
}
|
|
4884
|
+
sameTargetRequest = continuationRequest;
|
|
4885
|
+
sameTargetParsed = nextParsed;
|
|
4886
|
+
sameTargetToken = transportToken;
|
|
4887
|
+
}
|
|
4888
|
+
// Both branches assign the request (the build catch rethrows), so capture it in a
|
|
4889
|
+
// const for the fetch callback and finally below — a `let` read inside a nested
|
|
4890
|
+
// function keeps its undefined half, which would break the byte-identical replay.
|
|
4891
|
+
const builtContinuationRequest = continuationRequest;
|
|
4892
|
+
const continuationEstimate = typeof builtContinuationRequest.usageLog?.inputTokens === "number"
|
|
4893
|
+
? builtContinuationRequest.usageLog.inputTokens
|
|
4894
|
+
: undefined;
|
|
4895
|
+
if (continuationEstimate !== undefined) logCtx.usageLogInputTokens = continuationEstimate;
|
|
4896
|
+
// Optional recovery label for same-target / failover continuation sends.
|
|
4897
|
+
const replayKind: AttemptRecoveryKind | undefined = recoveryKind;
|
|
4898
|
+
try {
|
|
4899
|
+
if (activeAdapter.fetchResponse) {
|
|
4900
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind);
|
|
4901
|
+
await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal);
|
|
4902
|
+
return await activeAdapter.fetchResponse(builtContinuationRequest, {
|
|
4903
|
+
abortSignal: upstream.signal,
|
|
4904
|
+
timeoutMs: connectMs,
|
|
4905
|
+
stream: nextParsed.stream,
|
|
4906
|
+
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
4907
|
+
providerName: route.providerName,
|
|
4908
|
+
modelId: nextParsed.modelId,
|
|
4909
|
+
}),
|
|
4910
|
+
});
|
|
4911
|
+
}
|
|
4912
|
+
// Same #1851 scope guard as the initial send: transient-5xx retry only for direct
|
|
4913
|
+
// Google AI Studio; every other adapter keeps reset-only semantics here.
|
|
4914
|
+
const fetchContinuationWithRetryPolicy = route.provider.adapter === "google" ? fetchWithTransientRetry : fetchWithResetRetry;
|
|
4915
|
+
return await fetchContinuationWithRetryPolicy(
|
|
4916
|
+
recovery => {
|
|
4917
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind);
|
|
4918
|
+
return fetchWithHeaderTimeout(
|
|
4919
|
+
builtContinuationRequest.url,
|
|
4920
|
+
applyUpstreamRecoveryInit({
|
|
4921
|
+
method: builtContinuationRequest.method,
|
|
4922
|
+
headers: builtContinuationRequest.headers,
|
|
4923
|
+
body: builtContinuationRequest.body,
|
|
4924
|
+
}, recovery),
|
|
4925
|
+
upstream.signal,
|
|
4926
|
+
connectMs,
|
|
4927
|
+
nextParsed.stream,
|
|
4928
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
4929
|
+
providerName: route.providerName,
|
|
4930
|
+
modelId: nextParsed.modelId,
|
|
4931
|
+
}),
|
|
4932
|
+
);
|
|
4933
|
+
},
|
|
4934
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) },
|
|
4935
|
+
);
|
|
4936
|
+
} finally {
|
|
4937
|
+
builtContinuationRequest.releaseBodyObservation?.();
|
|
4938
|
+
}
|
|
4939
|
+
};
|
|
2495
4940
|
while (true) {
|
|
2496
4941
|
try {
|
|
2497
|
-
const
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
?
|
|
2505
|
-
|
|
2506
|
-
|
|
4942
|
+
const recoveryKind = nextContinuationRecoveryKind;
|
|
4943
|
+
nextContinuationRecoveryKind = undefined;
|
|
4944
|
+
response = await fetchContinuation(recoveryKind);
|
|
4945
|
+
} catch (error) {
|
|
4946
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
4947
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
4948
|
+
} else {
|
|
4949
|
+
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
4950
|
+
}
|
|
4951
|
+
return;
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`) before key/account failover:
|
|
4955
|
+
// a primary-key rate-limit blip replays on the SAME key, matching the main recovery
|
|
4956
|
+
// loop; only after the attempts are exhausted does the continuation fail over.
|
|
4957
|
+
while (
|
|
4958
|
+
response.status === 429
|
|
4959
|
+
&& rateLimitPolicy !== null
|
|
4960
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
4961
|
+
) {
|
|
4962
|
+
rateLimitRetries += 1;
|
|
4963
|
+
// Release unread body + heartbeat-fed wait via the shared same-target helper.
|
|
4964
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
2507
4965
|
try {
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
4966
|
+
yield* prepareSameTarget429Wait({
|
|
4967
|
+
body: response.body,
|
|
4968
|
+
// Listen on the upstream signal: once the SSE body is being streamed, a client
|
|
4969
|
+
// cancel aborts `upstream` through the bridge, and upstream is also linked from
|
|
4970
|
+
// options.abortSignal — so this covers both cancellation paths.
|
|
4971
|
+
signal: upstream.signal,
|
|
4972
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
4973
|
+
heartbeatIntervalMs: Math.min(10_000, Math.max(250, stallTimeoutMs / 2)),
|
|
4974
|
+
});
|
|
4975
|
+
} catch {
|
|
4976
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
4977
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
2515
4978
|
} else {
|
|
2516
|
-
|
|
2517
|
-
recovery => {
|
|
2518
|
-
noteAttemptSend(logCtx.activeAttempt, continuationEstimate, recovery);
|
|
2519
|
-
return fetchWithHeaderTimeout(
|
|
2520
|
-
continuationRequest.url,
|
|
2521
|
-
applyUpstreamRecoveryInit({
|
|
2522
|
-
method: continuationRequest.method,
|
|
2523
|
-
headers: continuationRequest.headers,
|
|
2524
|
-
body: continuationRequest.body,
|
|
2525
|
-
}, recovery),
|
|
2526
|
-
upstream.signal,
|
|
2527
|
-
connectMs,
|
|
2528
|
-
nextParsed.stream,
|
|
2529
|
-
providerFetch(route.provider),
|
|
2530
|
-
);
|
|
2531
|
-
},
|
|
2532
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(continuationRequest.url) },
|
|
2533
|
-
);
|
|
4979
|
+
yield { type: "error", message: "Provider continuation failed: retry wait interrupted" };
|
|
2534
4980
|
}
|
|
2535
|
-
|
|
2536
|
-
continuationRequest.releaseBodyObservation?.();
|
|
4981
|
+
return;
|
|
2537
4982
|
}
|
|
2538
|
-
|
|
2539
|
-
|
|
4983
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
4984
|
+
// replay so the continuation never starts work for a request the client abandoned.
|
|
4985
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
2540
4986
|
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
2541
|
-
|
|
2542
|
-
|
|
4987
|
+
return;
|
|
4988
|
+
}
|
|
4989
|
+
try {
|
|
4990
|
+
response = await fetchContinuation("rate-limit-429");
|
|
4991
|
+
} catch (error) {
|
|
4992
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
4993
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
4994
|
+
} else {
|
|
4995
|
+
yield { type: "error", message: `Provider continuation failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
4996
|
+
}
|
|
4997
|
+
return;
|
|
2543
4998
|
}
|
|
2544
|
-
return;
|
|
2545
4999
|
}
|
|
2546
5000
|
|
|
2547
5001
|
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
@@ -2554,10 +5008,26 @@ async function handleResponsesInner(
|
|
|
2554
5008
|
if (rotated) {
|
|
2555
5009
|
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
2556
5010
|
route.provider = rotated;
|
|
5011
|
+
invalidateSameTargetRequest();
|
|
2557
5012
|
activeAdapter = resolveAdapter(
|
|
2558
5013
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2559
5014
|
config.cacheRetention,
|
|
2560
5015
|
);
|
|
5016
|
+
bindRouteReasoningReplayScope({
|
|
5017
|
+
parsed: nextParsed,
|
|
5018
|
+
providerName: route.providerName,
|
|
5019
|
+
provider: route.provider,
|
|
5020
|
+
adapterName: activeAdapter.name,
|
|
5021
|
+
});
|
|
5022
|
+
// Response persistence closes over the outer parsed request; keep its owner binding in
|
|
5023
|
+
// sync with the terminal-guard clone that builds the rotated continuation request.
|
|
5024
|
+
bindRouteReasoningReplayScope({
|
|
5025
|
+
parsed,
|
|
5026
|
+
providerName: route.providerName,
|
|
5027
|
+
provider: route.provider,
|
|
5028
|
+
adapterName: activeAdapter.name,
|
|
5029
|
+
});
|
|
5030
|
+
nextContinuationRecoveryKind = "key-429";
|
|
2561
5031
|
continue;
|
|
2562
5032
|
}
|
|
2563
5033
|
}
|
|
@@ -2580,13 +5050,15 @@ async function handleResponsesInner(
|
|
|
2580
5050
|
anthropicPoolAccountId = nextAccountId;
|
|
2581
5051
|
anthropicPoolFailovers += 1;
|
|
2582
5052
|
route.provider = { ...route.provider, apiKey: accessToken };
|
|
5053
|
+
invalidateSameTargetRequest();
|
|
2583
5054
|
promoteAnthropicActiveAccount(nextAccountId);
|
|
2584
5055
|
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2585
5056
|
activeAdapter = resolveAdapter(
|
|
2586
5057
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2587
5058
|
config.cacheRetention,
|
|
2588
5059
|
);
|
|
2589
|
-
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
5060
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
|
|
5061
|
+
nextContinuationRecoveryKind = "anthropic-oauth-429";
|
|
2590
5062
|
continue;
|
|
2591
5063
|
} catch {
|
|
2592
5064
|
// fall through to emit continuation error below
|
|
@@ -2600,14 +5072,21 @@ async function handleResponsesInner(
|
|
|
2600
5072
|
alreadyAttempted: imageTierBias > 0,
|
|
2601
5073
|
})) {
|
|
2602
5074
|
imageTierBias = 1;
|
|
5075
|
+
invalidateSameTargetRequest();
|
|
2603
5076
|
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
5077
|
+
nextContinuationRecoveryKind = "image-413";
|
|
2604
5078
|
continue;
|
|
2605
5079
|
}
|
|
2606
5080
|
break;
|
|
2607
5081
|
}
|
|
2608
5082
|
|
|
2609
5083
|
if (!response.ok) {
|
|
5084
|
+
// Same pre-read guard as the initial response's error branch: a non-2xx continuation body
|
|
5085
|
+
// is still a Bun fetch body, and an abort landing before `.text()` attaches its reader
|
|
5086
|
+
// orphans the internal rejection.
|
|
5087
|
+
const detachContinuationErrorGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
2610
5088
|
const errorText = await response.text().catch(() => "unknown error");
|
|
5089
|
+
detachContinuationErrorGuard();
|
|
2611
5090
|
yield {
|
|
2612
5091
|
type: "error",
|
|
2613
5092
|
status: response.status,
|
|
@@ -2623,9 +5102,9 @@ async function handleResponsesInner(
|
|
|
2623
5102
|
const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
2624
5103
|
try {
|
|
2625
5104
|
if (nextParsed.stream) {
|
|
2626
|
-
yield* activeAdapter.parseStream(response, translatorBudget);
|
|
5105
|
+
yield* activeAdapter.parseStream(response, translatorBudget, logCtx.activeTierMetadata);
|
|
2627
5106
|
} else if (activeAdapter.parseResponse) {
|
|
2628
|
-
yield* await activeAdapter.parseResponse(response, translatorBudget);
|
|
5107
|
+
yield* await activeAdapter.parseResponse(response, translatorBudget, logCtx.activeTierMetadata);
|
|
2629
5108
|
} else {
|
|
2630
5109
|
yield { type: "error", message: "Provider continuation does not support response parsing" };
|
|
2631
5110
|
}
|
|
@@ -2641,8 +5120,25 @@ async function handleResponsesInner(
|
|
|
2641
5120
|
}
|
|
2642
5121
|
};
|
|
2643
5122
|
|
|
5123
|
+
const fetchGuardedEmptyCompletionRetry = (): AsyncIterable<AdapterEvent> => {
|
|
5124
|
+
const retryEvents = fetchTerminalGuardContinuation(parsed, "empty-completion");
|
|
5125
|
+
return terminalGuardEnabled
|
|
5126
|
+
? guardTerminalEventStream({
|
|
5127
|
+
parsed,
|
|
5128
|
+
firstEvents: retryEvents,
|
|
5129
|
+
adapterName: activeAdapter.name,
|
|
5130
|
+
maxAutoContinuations: 1,
|
|
5131
|
+
continuation: fetchTerminalGuardContinuation,
|
|
5132
|
+
})
|
|
5133
|
+
: retryEvents;
|
|
5134
|
+
};
|
|
5135
|
+
|
|
2644
5136
|
if (parsed.stream) {
|
|
2645
|
-
const initialEventStream = activeAdapter.parseStream(
|
|
5137
|
+
const initialEventStream = activeAdapter.parseStream(
|
|
5138
|
+
upstreamResponse,
|
|
5139
|
+
translatorBudget,
|
|
5140
|
+
logCtx.activeTierMetadata,
|
|
5141
|
+
);
|
|
2646
5142
|
const eventStream = terminalGuardEnabled
|
|
2647
5143
|
? guardTerminalEventStream({
|
|
2648
5144
|
parsed,
|
|
@@ -2652,17 +5148,32 @@ async function handleResponsesInner(
|
|
|
2652
5148
|
continuation: fetchTerminalGuardContinuation,
|
|
2653
5149
|
})
|
|
2654
5150
|
: initialEventStream;
|
|
2655
|
-
|
|
5151
|
+
// The empty-completion guard sits OUTSIDE the terminal guard: a completed
|
|
5152
|
+
// turn with no text and no tool call is retried with the IDENTICAL request
|
|
5153
|
+
// (fetchTerminalGuardContinuation(parsed) replays the cached byte-identical
|
|
5154
|
+
// request — same body, same headers, same signal).
|
|
5155
|
+
const guardedEventStream = emptyCompletionGuardEnabled
|
|
5156
|
+
? guardEmptyCompletionEventStream({
|
|
5157
|
+
firstEvents: eventStream,
|
|
5158
|
+
continuation: fetchGuardedEmptyCompletionRetry,
|
|
5159
|
+
})
|
|
5160
|
+
: eventStream;
|
|
5161
|
+
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2656
5162
|
const sseStream = bridgeToResponsesSSE(
|
|
2657
|
-
|
|
5163
|
+
guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
2658
5164
|
() => upstream.abort(), 2_000,
|
|
2659
5165
|
{
|
|
2660
5166
|
translatorBudget,
|
|
5167
|
+
replayCacheScope: parsed._reasoningReplayScope,
|
|
2661
5168
|
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
2662
5169
|
stallTimeoutSec: config.stallTimeoutSec,
|
|
2663
5170
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
5171
|
+
declaredToolNames,
|
|
5172
|
+
toolParameterSchemas,
|
|
2664
5173
|
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2665
5174
|
...(routedCompaction ? { compaction: true } : {}),
|
|
5175
|
+
// Same grok-surface split as the runTurn branch above.
|
|
5176
|
+
...(logCtx.surface === "grok" ? { heartbeatStyle: "comment" as const } : {}),
|
|
2666
5177
|
onUsage: usage => {
|
|
2667
5178
|
// Raw adapter usage, pre wire-normalization (see the runTurn branch above).
|
|
2668
5179
|
logCtx.usageFromBridge = true;
|
|
@@ -2671,18 +5182,20 @@ async function handleResponsesInner(
|
|
|
2671
5182
|
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2672
5183
|
}
|
|
2673
5184
|
},
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
5185
|
+
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
|
|
5186
|
+
commitReasoningReplayServingRoute();
|
|
5187
|
+
// Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
|
|
5188
|
+
// PRE-compaction history, and a later previous_response_id expansion would rehydrate the
|
|
5189
|
+
// giant stale chain Codex just replaced.
|
|
5190
|
+
if (!routedCompaction) {
|
|
2679
5191
|
rememberResponseState(
|
|
2680
5192
|
parsed._rawBody,
|
|
2681
5193
|
response,
|
|
2682
5194
|
continuationStateForResponse(providerState),
|
|
2683
|
-
activeAdapter.name === "kiro"
|
|
2684
|
-
)
|
|
2685
|
-
|
|
5195
|
+
responseStateOptions(activeAdapter.name === "kiro"),
|
|
5196
|
+
);
|
|
5197
|
+
}
|
|
5198
|
+
},
|
|
2686
5199
|
},
|
|
2687
5200
|
);
|
|
2688
5201
|
const bridgeTurnAc = new AbortController();
|
|
@@ -2695,28 +5208,45 @@ async function handleResponsesInner(
|
|
|
2695
5208
|
if (activeAdapter.parseResponse) {
|
|
2696
5209
|
let events: AdapterEvent[];
|
|
2697
5210
|
try {
|
|
2698
|
-
const initialEvents = await activeAdapter.parseResponse(
|
|
5211
|
+
const initialEvents = await activeAdapter.parseResponse(
|
|
5212
|
+
upstreamResponse,
|
|
5213
|
+
translatorBudget,
|
|
5214
|
+
logCtx.activeTierMetadata,
|
|
5215
|
+
);
|
|
5216
|
+
let guardedEvents: AdapterEvent[];
|
|
2699
5217
|
if (terminalGuardEnabled) {
|
|
2700
|
-
|
|
5218
|
+
guardedEvents = [];
|
|
2701
5219
|
for await (const event of guardTerminalEventStream({
|
|
2702
5220
|
parsed,
|
|
2703
5221
|
firstEvents: (async function* () { yield* initialEvents; })(),
|
|
2704
5222
|
adapterName: activeAdapter.name,
|
|
2705
5223
|
maxAutoContinuations: 1,
|
|
2706
5224
|
continuation: fetchTerminalGuardContinuation,
|
|
5225
|
+
})) guardedEvents.push(event);
|
|
5226
|
+
} else {
|
|
5227
|
+
guardedEvents = initialEvents;
|
|
5228
|
+
}
|
|
5229
|
+
if (emptyCompletionGuardEnabled) {
|
|
5230
|
+
events = [];
|
|
5231
|
+
for await (const event of guardEmptyCompletionEventStream({
|
|
5232
|
+
firstEvents: (async function* () { yield* guardedEvents; })(),
|
|
5233
|
+
continuation: fetchGuardedEmptyCompletionRetry,
|
|
2707
5234
|
})) events.push(event);
|
|
2708
5235
|
} else {
|
|
2709
|
-
events =
|
|
5236
|
+
events = guardedEvents;
|
|
2710
5237
|
}
|
|
2711
5238
|
} finally {
|
|
2712
5239
|
cleanupUpstreamAbort();
|
|
2713
5240
|
}
|
|
2714
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
5241
|
+
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2715
5242
|
let providerState: OcxProviderContinuationState | undefined;
|
|
2716
|
-
const json = buildResponseJSON(events, parsed.modelId, {
|
|
5243
|
+
const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
|
|
2717
5244
|
translatorBudget,
|
|
5245
|
+
replayCacheScope: parsed._reasoningReplayScope,
|
|
2718
5246
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2719
5247
|
toolNsMap,
|
|
5248
|
+
declaredToolNames,
|
|
5249
|
+
toolParameterSchemas,
|
|
2720
5250
|
freeformToolNames,
|
|
2721
5251
|
toolSearchToolNames,
|
|
2722
5252
|
...(routedCompaction ? { compaction: true } : {}),
|
|
@@ -2735,13 +5265,24 @@ async function handleResponsesInner(
|
|
|
2735
5265
|
parsed._rawBody,
|
|
2736
5266
|
json,
|
|
2737
5267
|
continuationStateForResponse(providerState),
|
|
2738
|
-
activeAdapter.name === "kiro"
|
|
5268
|
+
responseStateOptions(activeAdapter.name === "kiro"),
|
|
2739
5269
|
);
|
|
2740
5270
|
}
|
|
5271
|
+
// #1926 gap 2: same buffered-path durability bound as the primary branch.
|
|
5272
|
+
await awaitThoughtSignatureDurability();
|
|
5273
|
+
if (adapterResponseReachedServingTerminal(events, json)) {
|
|
5274
|
+
commitReasoningReplayServingRoute();
|
|
5275
|
+
}
|
|
2741
5276
|
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
2742
5277
|
}
|
|
2743
5278
|
|
|
2744
5279
|
return formatErrorResponse(400, "invalid_request_error", "Non-streaming not supported by this adapter");
|
|
5280
|
+
} finally {
|
|
5281
|
+
if (pendingHostAdmissionLease) {
|
|
5282
|
+
releaseUpstreamHostAdmission(pendingHostAdmissionLease);
|
|
5283
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
2745
5286
|
}
|
|
2746
5287
|
|
|
2747
5288
|
|