@iislee/opencodex 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/assets/architecture.png +0 -0
- package/assets/banner.png +0 -0
- package/assets/claude-code-models.gif +0 -0
- package/assets/codex-app-picker.png +0 -0
- package/bin/ocx.mjs +451 -0
- package/bin/package-main.mjs +9 -0
- package/gui/dist/assets/index-DTpMHS4F.js +67 -0
- package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/icons.svg +24 -0
- package/gui/dist/index.html +25 -0
- package/gui/dist/logo.png +0 -0
- package/gui/dist/provider-icons/alibaba-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity-color.svg +1 -0
- package/gui/dist/provider-icons/antigravity.svg +1 -0
- package/gui/dist/provider-icons/claude-color.svg +1 -0
- package/gui/dist/provider-icons/claude.svg +1 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/copilot-color.svg +1 -0
- package/gui/dist/provider-icons/copilot.svg +1 -0
- package/gui/dist/provider-icons/cursor-color.svg +2 -0
- package/gui/dist/provider-icons/cursor.svg +2 -0
- package/gui/dist/provider-icons/deepseek-color.svg +1 -0
- package/gui/dist/provider-icons/discord.svg +1 -0
- package/gui/dist/provider-icons/firepass-color.svg +1 -0
- package/gui/dist/provider-icons/fireworks-color.svg +1 -0
- package/gui/dist/provider-icons/gemini-color.svg +1 -0
- package/gui/dist/provider-icons/gemini.svg +1 -0
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
- package/gui/dist/provider-icons/grok-color.svg +1 -0
- package/gui/dist/provider-icons/grok.svg +1 -0
- package/gui/dist/provider-icons/groq-color.svg +1 -0
- package/gui/dist/provider-icons/huggingface-color.svg +1 -0
- package/gui/dist/provider-icons/kimi-color.svg +1 -0
- package/gui/dist/provider-icons/kiro-color.svg +15 -0
- package/gui/dist/provider-icons/kiro.svg +14 -0
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
- package/gui/dist/provider-icons/mistral-color.svg +1 -0
- package/gui/dist/provider-icons/moonshot-color.svg +1 -0
- package/gui/dist/provider-icons/nvidia-color.svg +1 -0
- package/gui/dist/provider-icons/ollama-color.svg +1 -0
- package/gui/dist/provider-icons/openai.svg +1 -0
- package/gui/dist/provider-icons/opencode.svg +1 -0
- package/gui/dist/provider-icons/openrouter-color.svg +1 -0
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/gui/dist/provider-icons/qianfan-color.svg +1 -0
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
- package/gui/dist/provider-icons/telegram.svg +1 -0
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
- package/gui/dist/provider-icons/vllm-color.svg +1 -0
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
- package/package.json +102 -0
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic-image-guard.ts +251 -0
- package/src/adapters/anthropic-image-normalize.ts +518 -0
- package/src/adapters/anthropic.ts +1003 -0
- package/src/adapters/azure.ts +36 -0
- package/src/adapters/base.ts +72 -0
- package/src/adapters/client-fingerprint.ts +59 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +104 -0
- package/src/adapters/cursor/cursor-errors.ts +165 -0
- package/src/adapters/cursor/discovery.ts +276 -0
- package/src/adapters/cursor/effort-map.ts +127 -0
- package/src/adapters/cursor/exec-policy.ts +88 -0
- package/src/adapters/cursor/framing.ts +211 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +52 -0
- package/src/adapters/cursor/live-models.ts +153 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +1214 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +333 -0
- package/src/adapters/cursor/message-mapper.ts +49 -0
- package/src/adapters/cursor/native-exec-common.ts +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +184 -0
- package/src/adapters/cursor/native-exec-fs.ts +329 -0
- package/src/adapters/cursor/native-exec-mcp.ts +153 -0
- package/src/adapters/cursor/native-exec-network.ts +43 -0
- package/src/adapters/cursor/native-exec-shell.ts +548 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +576 -0
- package/src/adapters/cursor/protobuf-events.ts +563 -0
- package/src/adapters/cursor/protobuf-request.ts +714 -0
- package/src/adapters/cursor/request-builder.ts +255 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/tool-definitions.ts +505 -0
- package/src/adapters/cursor/transport-retry.ts +132 -0
- package/src/adapters/cursor/transport.ts +57 -0
- package/src/adapters/cursor/types.ts +52 -0
- package/src/adapters/cursor.ts +196 -0
- package/src/adapters/google-antigravity-replay.ts +303 -0
- package/src/adapters/google-antigravity-wire.ts +108 -0
- package/src/adapters/google-errors.ts +85 -0
- package/src/adapters/google-http.ts +100 -0
- package/src/adapters/google-tool-schema.ts +173 -0
- package/src/adapters/google-truncation.ts +13 -0
- package/src/adapters/google-wire-compiler.ts +232 -0
- package/src/adapters/google.ts +758 -0
- package/src/adapters/identity.ts +44 -0
- package/src/adapters/image.ts +23 -0
- package/src/adapters/kiro-constants.ts +16 -0
- package/src/adapters/kiro-errors.ts +197 -0
- package/src/adapters/kiro-events.ts +179 -0
- package/src/adapters/kiro-images.ts +129 -0
- package/src/adapters/kiro-retry.ts +312 -0
- package/src/adapters/kiro-thinking.ts +96 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +215 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +129 -0
- package/src/adapters/kiro.ts +1898 -0
- package/src/adapters/mimo-free.ts +263 -0
- package/src/adapters/openai-chat.ts +1005 -0
- package/src/adapters/openai-responses.ts +1137 -0
- package/src/adapters/run-turn-queue.ts +114 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/bridge.ts +1619 -0
- package/src/chat/inbound.ts +295 -0
- package/src/chat/outbound.ts +765 -0
- package/src/claude/agents-inject.ts +243 -0
- package/src/claude/alias.ts +149 -0
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p-paths.ts +84 -0
- package/src/claude/desktop-3p.ts +381 -0
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +163 -0
- package/src/claude/inbound.ts +509 -0
- package/src/claude/model-info.ts +151 -0
- package/src/claude/outbound.ts +872 -0
- package/src/cli/access.ts +108 -0
- package/src/cli/account-api.ts +268 -0
- package/src/cli/account-auth.ts +223 -0
- package/src/cli/account-extended.ts +350 -0
- package/src/cli/account.ts +275 -0
- package/src/cli/agent-driven.ts +70 -0
- package/src/cli/agent.ts +184 -0
- package/src/cli/catalog-prewarm.ts +27 -0
- package/src/cli/claude-desktop.ts +188 -0
- package/src/cli/claude.ts +286 -0
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/combo.ts +119 -0
- package/src/cli/config-command.ts +145 -0
- package/src/cli/debug.ts +228 -0
- package/src/cli/doctor.ts +930 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +354 -0
- package/src/cli/index.ts +1113 -0
- package/src/cli/init.ts +224 -0
- package/src/cli/integrations.ts +142 -0
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/internal-dispatch.ts +20 -0
- package/src/cli/models-runtime.ts +212 -0
- package/src/cli/models.ts +336 -0
- package/src/cli/observe.ts +117 -0
- package/src/cli/opencode.ts +586 -0
- package/src/cli/pi.ts +188 -0
- package/src/cli/provider-runtime.ts +162 -0
- package/src/cli/provider.ts +463 -0
- package/src/cli/runtime-api.ts +325 -0
- package/src/cli/star-prompt.ts +155 -0
- package/src/cli/status-oauth.ts +78 -0
- package/src/cli/status.ts +321 -0
- package/src/cli/sync-cloud.ts +283 -0
- package/src/cli/system-command.ts +112 -0
- package/src/cli/tray-proxy.ts +52 -0
- package/src/cli/v2.ts +173 -0
- package/src/cli.ts +10 -0
- package/src/clients/config-export.ts +377 -0
- package/src/clients/effective-status.ts +385 -0
- package/src/clients/probes/agy.ts +55 -0
- package/src/clients/probes/cc-switch.ts +110 -0
- package/src/clients/probes/claude.ts +90 -0
- package/src/clients/probes/codex.ts +125 -0
- package/src/clients/probes/grok.ts +29 -0
- package/src/clients/probes/opencode.ts +109 -0
- package/src/clients/probes/paseo.ts +55 -0
- package/src/clients/probes/pi.ts +55 -0
- package/src/cloud/onedrive-auth.ts +666 -0
- package/src/cloud/onedrive-graph.ts +108 -0
- package/src/cloud/settings.ts +75 -0
- package/src/cloud/sync.ts +212 -0
- package/src/cloud/types.ts +56 -0
- package/src/cloud/vault.ts +89 -0
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-label.ts +34 -0
- package/src/codex/account-lifecycle.ts +55 -0
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-runtime-state.ts +31 -0
- package/src/codex/account-store.ts +517 -0
- package/src/codex/account-usability.ts +20 -0
- package/src/codex/app-server-processes.ts +756 -0
- package/src/codex/auth-api.ts +1540 -0
- package/src/codex/auth-collision.ts +107 -0
- package/src/codex/auth-context.ts +352 -0
- package/src/codex/autostart-health.ts +149 -0
- package/src/codex/catalog/aggregation.ts +378 -0
- package/src/codex/catalog/bundled.ts +251 -0
- package/src/codex/catalog/effort.ts +355 -0
- package/src/codex/catalog/metadata.ts +180 -0
- package/src/codex/catalog/parsing.ts +456 -0
- package/src/codex/catalog/provider-fetch.ts +902 -0
- package/src/codex/catalog/sync.ts +620 -0
- package/src/codex/catalog.ts +12 -0
- package/src/codex/data/upstream-models.json +830 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/features.ts +969 -0
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/codex/history-provider.ts +776 -0
- package/src/codex/home.ts +206 -0
- package/src/codex/inject.ts +799 -0
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +163 -0
- package/src/codex/main-account-cache.ts +32 -0
- package/src/codex/main-account.ts +40 -0
- package/src/codex/model-cache.ts +227 -0
- package/src/codex/paths.ts +65 -0
- package/src/codex/plugins-doctor.ts +242 -0
- package/src/codex/pool-rotation.ts +225 -0
- package/src/codex/project-config-warnings.ts +411 -0
- package/src/codex/quota.ts +411 -0
- package/src/codex/refresh.ts +53 -0
- package/src/codex/routing.ts +1477 -0
- package/src/codex/runtime.ts +538 -0
- package/src/codex/shim.ts +1189 -0
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +469 -0
- package/src/codex/sync.ts +130 -0
- package/src/codex/warmup.ts +192 -0
- package/src/codex/websocket-registry.ts +100 -0
- package/src/combos/failover.ts +140 -0
- package/src/combos/index.ts +41 -0
- package/src/combos/request.ts +62 -0
- package/src/combos/resolve.ts +232 -0
- package/src/combos/types.ts +326 -0
- package/src/config.ts +2356 -0
- package/src/generated/jawcode-model-metadata.ts +104 -0
- package/src/github/star-state.ts +203 -0
- package/src/grok/inject.ts +545 -0
- package/src/grok/status.ts +121 -0
- package/src/grok/sync.ts +103 -0
- package/src/grok/usage-hook/report.mjs +348 -0
- package/src/grok/usage-hook.ts +278 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +149 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +829 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/index.ts +22 -0
- package/src/lib/abort.ts +146 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +71 -0
- package/src/lib/bun-stream-caps.ts +126 -0
- package/src/lib/config-ownership.ts +360 -0
- package/src/lib/crash-guard.ts +344 -0
- package/src/lib/debug-log-buffer.ts +83 -0
- package/src/lib/debug-settings.ts +108 -0
- package/src/lib/debug.ts +31 -0
- package/src/lib/destination-policy.ts +316 -0
- package/src/lib/errors.ts +364 -0
- package/src/lib/eventstream-decoder.ts +253 -0
- package/src/lib/gcp-adc.ts +341 -0
- package/src/lib/injection-debug-log.ts +58 -0
- package/src/lib/open-url.ts +25 -0
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/privacy.ts +20 -0
- package/src/lib/process-control.ts +165 -0
- package/src/lib/provider-outbound.ts +170 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/redact.ts +105 -0
- package/src/lib/retry-after.ts +55 -0
- package/src/lib/service-secrets.ts +25 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/sidecar-tracker.ts +52 -0
- package/src/lib/sse-decoder.ts +323 -0
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/token-estimate.ts +69 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/upstream-retry.ts +239 -0
- package/src/lib/win-exec.ts +115 -0
- package/src/lib/win-paths.ts +68 -0
- package/src/lib/windows-elevation.ts +705 -0
- package/src/lib/windows-secret-acl.ts +514 -0
- package/src/lib/winsw.ts +375 -0
- package/src/oauth/anthropic-routing.ts +594 -0
- package/src/oauth/anthropic.ts +177 -0
- package/src/oauth/callback-server.ts +294 -0
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/cursor.ts +211 -0
- package/src/oauth/github-copilot.ts +428 -0
- package/src/oauth/google-antigravity.ts +230 -0
- package/src/oauth/health.ts +399 -0
- package/src/oauth/index.ts +1174 -0
- package/src/oauth/key-providers.ts +108 -0
- package/src/oauth/kimi.ts +213 -0
- package/src/oauth/kiro-credentials.ts +726 -0
- package/src/oauth/kiro.ts +577 -0
- package/src/oauth/local-token-detect.ts +121 -0
- package/src/oauth/log.ts +48 -0
- package/src/oauth/login-cli.ts +163 -0
- package/src/oauth/pkce.ts +15 -0
- package/src/oauth/store.ts +630 -0
- package/src/oauth/token-guardian.ts +303 -0
- package/src/oauth/types.ts +62 -0
- package/src/oauth/xai.ts +241 -0
- package/src/pi/extensions.ts +72 -0
- package/src/pi/home.ts +42 -0
- package/src/pi/index.ts +40 -0
- package/src/pi/models.ts +278 -0
- package/src/pi/packages.ts +219 -0
- package/src/pi/settings.ts +365 -0
- package/src/pi/status.ts +68 -0
- package/src/pi/sync.ts +75 -0
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +156 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/antigravity-models.ts +205 -0
- package/src/providers/api-keys.ts +140 -0
- package/src/providers/base-url-choices.ts +64 -0
- package/src/providers/context-cap.ts +65 -0
- package/src/providers/derive.ts +339 -0
- package/src/providers/free-directory.ts +184 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/key-failover.ts +203 -0
- package/src/providers/kiro-models.ts +67 -0
- package/src/providers/label.ts +19 -0
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/openai-sidecar.ts +175 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +301 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/quota.ts +1260 -0
- package/src/providers/registry.ts +1600 -0
- package/src/providers/slug-codec.ts +67 -0
- package/src/providers/xai-transport.ts +141 -0
- package/src/reasoning-effort.ts +135 -0
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +656 -0
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +159 -0
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +895 -0
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +425 -0
- package/src/server/adapter-resolve.ts +80 -0
- package/src/server/auth-cors.ts +530 -0
- package/src/server/chat-completions.ts +368 -0
- package/src/server/claude-messages.ts +914 -0
- package/src/server/effort-policy.ts +172 -0
- package/src/server/gui-static.ts +123 -0
- package/src/server/image-retry.ts +42 -0
- package/src/server/images.ts +476 -0
- package/src/server/index.ts +1126 -0
- package/src/server/lifecycle.ts +227 -0
- package/src/server/live.ts +598 -0
- package/src/server/management/agent-settings-routes.ts +1169 -0
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/clients-routes.ts +63 -0
- package/src/server/management/cloud-sync-routes.ts +266 -0
- package/src/server/management/combo-routes.ts +220 -0
- package/src/server/management/config-routes.ts +422 -0
- package/src/server/management/context.ts +31 -0
- package/src/server/management/logs-usage-routes.ts +707 -0
- package/src/server/management/model-routes.ts +525 -0
- package/src/server/management/oauth-account-routes.ts +563 -0
- package/src/server/management/provider-routes.ts +556 -0
- package/src/server/management/shared.ts +277 -0
- package/src/server/management/sidebar-routes.ts +90 -0
- package/src/server/management/system-restart.ts +179 -0
- package/src/server/management/system-routes.ts +117 -0
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +215 -0
- package/src/server/management-auth.ts +267 -0
- package/src/server/memory-watchdog.ts +156 -0
- package/src/server/port-reclaim.ts +307 -0
- package/src/server/ports.ts +116 -0
- package/src/server/proxy-liveness.ts +201 -0
- package/src/server/relay-eager.ts +313 -0
- package/src/server/relay.ts +1049 -0
- package/src/server/request-decompress.ts +132 -0
- package/src/server/request-log-conversation.ts +168 -0
- package/src/server/request-log.ts +1046 -0
- package/src/server/responses/collaboration.ts +354 -0
- package/src/server/responses/compact.ts +384 -0
- package/src/server/responses/core.ts +2758 -0
- package/src/server/responses/encrypted-payload.ts +308 -0
- package/src/server/responses/fetch-helpers.ts +157 -0
- package/src/server/responses/passthrough-error.ts +78 -0
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +132 -0
- package/src/server/responses-item-id-repair.ts +224 -0
- package/src/server/responses.ts +9 -0
- package/src/server/search.ts +136 -0
- package/src/server/sse-payload-rewrite.ts +175 -0
- package/src/server/startup-action-control.ts +308 -0
- package/src/server/startup-health-cache.ts +113 -0
- package/src/server/system-env.ts +413 -0
- package/src/server/windows-tcp-drop.ts +184 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/server/ws-bridge.ts +471 -0
- package/src/service.ts +2554 -0
- package/src/stall-timeout.ts +20 -0
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +3085 -0
- package/src/storage/policy-job.ts +457 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +59 -0
- package/src/storage/policy.ts +527 -0
- package/src/storage/restore-job.ts +299 -0
- package/src/storage/restore-worker.ts +58 -0
- package/src/storage/scanner.ts +238 -0
- package/src/storage/storage-mutation-coordinator.ts +139 -0
- package/src/storage/worker-lifecycle.ts +215 -0
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +290 -0
- package/src/tray/windows.ts +730 -0
- package/src/types.ts +1237 -0
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +407 -0
- package/src/update/job.ts +1520 -0
- package/src/update/notify.ts +257 -0
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/debug.ts +97 -0
- package/src/usage/expected-prices.ts +164 -0
- package/src/usage/log.ts +658 -0
- package/src/usage/summary.ts +585 -0
- package/src/usage/totals.ts +14 -0
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/describe.ts +125 -0
- package/src/vision/index.ts +467 -0
- package/src/web-search/anthropic-executor.ts +189 -0
- package/src/web-search/executor.ts +105 -0
- package/src/web-search/format-result.ts +89 -0
- package/src/web-search/index.ts +196 -0
- package/src/web-search/loop.ts +664 -0
- package/src/web-search/parse.ts +220 -0
- package/src/web-search/progress-stream.ts +342 -0
- package/src/web-search/synthetic-tool.ts +47 -0
|
@@ -0,0 +1,2758 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
|
+
import { formatPassthroughUpstreamError } from "./passthrough-error";
|
|
4
|
+
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
5
|
+
import {
|
|
6
|
+
getConfigPath,
|
|
7
|
+
multiAgentGuidanceEnabled,
|
|
8
|
+
resolveEnvValue,
|
|
9
|
+
} from "../../config";
|
|
10
|
+
import { parseRequest } from "../../responses/parser";
|
|
11
|
+
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
|
|
12
|
+
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
|
|
13
|
+
import {
|
|
14
|
+
expandPreviousResponseInput,
|
|
15
|
+
previousResponseProviderState,
|
|
16
|
+
previousResponseReplayFailure,
|
|
17
|
+
rememberResponseState,
|
|
18
|
+
} from "../../responses/state";
|
|
19
|
+
import { routeModel, type RouteResult } from "../../router";
|
|
20
|
+
import {
|
|
21
|
+
advanceComboAfterFailure,
|
|
22
|
+
comboDefaultEffort,
|
|
23
|
+
comboFailureDecision,
|
|
24
|
+
comboIdFromRawBody,
|
|
25
|
+
concreteComboRequestBody,
|
|
26
|
+
getCombo,
|
|
27
|
+
isComboTargetInCooldown,
|
|
28
|
+
NoAvailableComboTargetsError,
|
|
29
|
+
noteComboSuccess,
|
|
30
|
+
parseRetryAfterMs,
|
|
31
|
+
pickComboTarget,
|
|
32
|
+
targetKey,
|
|
33
|
+
} from "../../combos";
|
|
34
|
+
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
35
|
+
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
36
|
+
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
37
|
+
import { modelInList, namespacedToolName } from "../../types";
|
|
38
|
+
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
39
|
+
import {
|
|
40
|
+
forceRefreshOAuthAccessSnapshot,
|
|
41
|
+
getOAuthCredentialApiBaseUrl,
|
|
42
|
+
getOAuthCredentialProjectId,
|
|
43
|
+
getValidAccessTokenForAccount,
|
|
44
|
+
getValidAccessTokenSnapshot,
|
|
45
|
+
type OAuthAccessSnapshot,
|
|
46
|
+
UnsupportedOAuthProviderError,
|
|
47
|
+
} from "../../oauth";
|
|
48
|
+
import {
|
|
49
|
+
ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
|
|
50
|
+
anthropicSessionKeyFromParts,
|
|
51
|
+
bindAnthropicSessionAffinity,
|
|
52
|
+
formatAnthropicProviderForLog,
|
|
53
|
+
getAnthropicPoolAccessToken,
|
|
54
|
+
getAnthropicPoolRetryAfterSeconds,
|
|
55
|
+
isAnthropicAccountPoolEnabled,
|
|
56
|
+
promoteAnthropicActiveAccount,
|
|
57
|
+
resolveAnthropicAccountForSession,
|
|
58
|
+
rotateAnthropicAccountOn429,
|
|
59
|
+
} from "../../oauth/anthropic-routing";
|
|
60
|
+
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
61
|
+
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
62
|
+
import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
63
|
+
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
64
|
+
import {
|
|
65
|
+
applyCodexAuthContextToProvider,
|
|
66
|
+
CodexAccountCooldownError,
|
|
67
|
+
cooldownErrorResponse,
|
|
68
|
+
CodexAuthContextError,
|
|
69
|
+
CodexDirectAuthenticationError,
|
|
70
|
+
CodexPoolAuthenticationError,
|
|
71
|
+
CodexThreadAffinityExpiredError,
|
|
72
|
+
headersForCodexAuthContext,
|
|
73
|
+
isCodexAuthContextUsable,
|
|
74
|
+
resolveCodexAuthContext,
|
|
75
|
+
codexProbeLeaseId,
|
|
76
|
+
codexProbeQuotaScope,
|
|
77
|
+
releaseCodexAuthContextProbeLease,
|
|
78
|
+
stripCodexRuntimeProviderFields,
|
|
79
|
+
type CodexAuthContext,
|
|
80
|
+
} from "../../codex/auth-context";
|
|
81
|
+
import {
|
|
82
|
+
computeQuotaCooldown,
|
|
83
|
+
formatCodexProviderForLog,
|
|
84
|
+
previewCodexAccountForRequest,
|
|
85
|
+
recordCodexUpstreamOutcome,
|
|
86
|
+
type CodexUpstreamOutcome,
|
|
87
|
+
} from "../../codex/routing";
|
|
88
|
+
import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry";
|
|
89
|
+
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
90
|
+
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
|
|
91
|
+
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
92
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
93
|
+
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
94
|
+
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
95
|
+
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
96
|
+
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
|
|
97
|
+
import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
|
|
98
|
+
import type { InboundWire } from "../../providers/registry";
|
|
99
|
+
import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
|
|
100
|
+
import { shouldAttemptImageTierRetry } from "../image-retry";
|
|
101
|
+
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
102
|
+
import type { WsData } from "../ws-bridge";
|
|
103
|
+
import { registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
104
|
+
import { redactSecretString } from "../../lib/redact";
|
|
105
|
+
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
106
|
+
import type { AdmissionLease } from "../../lib/admission";
|
|
107
|
+
import { supportedLadderFor } from "../effort-policy";
|
|
108
|
+
import { isThreadSpawnRequest } from "../effort-policy";
|
|
109
|
+
import {
|
|
110
|
+
applySubagentModelFallback,
|
|
111
|
+
maybePrimeSubagentQuota,
|
|
112
|
+
recordSubagentQuotaFailureForThreadSpawn,
|
|
113
|
+
} from "../../codex/subagent-model-fallback";
|
|
114
|
+
import {
|
|
115
|
+
beginRequestAttempt,
|
|
116
|
+
catalogModelSupportsServiceTier,
|
|
117
|
+
finishRequestAttempt,
|
|
118
|
+
inspectResponseLogJson,
|
|
119
|
+
noteAttemptSend,
|
|
120
|
+
readConfiguredCodexServiceTier,
|
|
121
|
+
recordAdapterReasoning,
|
|
122
|
+
recordAttemptRequestedEffort,
|
|
123
|
+
requestLogSpeedLabel,
|
|
124
|
+
sealRequestAttemptIdentity,
|
|
125
|
+
usageFromResponsesPayload,
|
|
126
|
+
type RequestLogContext,
|
|
127
|
+
} from "../request-log";
|
|
128
|
+
import {
|
|
129
|
+
conversationIdFromResponsesRequest,
|
|
130
|
+
normalizeLogConversationId,
|
|
131
|
+
sessionIdHeaderFromRequest,
|
|
132
|
+
} from "../request-log-conversation";
|
|
133
|
+
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
134
|
+
import {
|
|
135
|
+
consumeForInspection,
|
|
136
|
+
consumeForResponseLogMetadata,
|
|
137
|
+
createSseInspector,
|
|
138
|
+
isEagerRelaySseResponse,
|
|
139
|
+
isNativePassthroughSseResponse,
|
|
140
|
+
markEagerRelaySseResponse,
|
|
141
|
+
markNativePassthroughSseResponse,
|
|
142
|
+
relaySseWithFailedTail,
|
|
143
|
+
relayWithAbort,
|
|
144
|
+
sanitizePassthroughHeaders,
|
|
145
|
+
} from "../relay";
|
|
146
|
+
import { relaySseEagerBounded } from "../relay-eager";
|
|
147
|
+
import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps";
|
|
148
|
+
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
149
|
+
import {
|
|
150
|
+
createResponsesItemIdPayloadRewrite,
|
|
151
|
+
hasResponsesItemIdRepair,
|
|
152
|
+
} from "../responses-item-id-repair";
|
|
153
|
+
import {
|
|
154
|
+
createImageGenCallRestoreRewrite,
|
|
155
|
+
imageGenToolCallAliases,
|
|
156
|
+
restoreImageGenCallsInJson,
|
|
157
|
+
} from "../responses-image-gen-repair";
|
|
158
|
+
import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
|
|
159
|
+
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
160
|
+
|
|
161
|
+
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
162
|
+
import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
|
|
163
|
+
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./fetch-helpers";
|
|
164
|
+
import { guardTerminalEventStream } from "./terminal-guard";
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Adapters whose continuation state must survive Codex's store:false requests.
|
|
168
|
+
*/
|
|
169
|
+
export function adapterNeedsForcedContinuation(name: string): boolean {
|
|
170
|
+
return name === "kiro" || name === "cursor";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function sidecarOutcomeRecorder(
|
|
174
|
+
config: OcxConfig,
|
|
175
|
+
authCtx: CodexAuthContext,
|
|
176
|
+
threadId?: string | null,
|
|
177
|
+
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
178
|
+
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
179
|
+
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
180
|
+
threadId,
|
|
181
|
+
probeLeaseId: authCtx.probeLeaseId,
|
|
182
|
+
writerGeneration: authCtx.writerGeneration,
|
|
183
|
+
})
|
|
184
|
+
: undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
import { isShadowSourceModel } from "../../lib/shadow-call";
|
|
190
|
+
|
|
191
|
+
export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
export function codexLogAccountId(authCtx: CodexAuthContext): string | null {
|
|
196
|
+
return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
export function usesCodexForwardPoolAuth(
|
|
202
|
+
authCtx: CodexAuthContext,
|
|
203
|
+
provider: OcxProviderConfig,
|
|
204
|
+
): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
|
|
205
|
+
return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
206
|
+
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeCodexUnsupportedModelDetail(value: string): string {
|
|
210
|
+
return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isAllowListedCodexAccountModel400(
|
|
214
|
+
status: number,
|
|
215
|
+
bodyText: string,
|
|
216
|
+
modelId: string,
|
|
217
|
+
): boolean {
|
|
218
|
+
if (status !== 400) return false;
|
|
219
|
+
try {
|
|
220
|
+
const payload = JSON.parse(bodyText) as unknown;
|
|
221
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
222
|
+
const detail = (payload as { detail?: unknown }).detail;
|
|
223
|
+
if (typeof detail !== "string") return false;
|
|
224
|
+
const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`;
|
|
225
|
+
return normalizeCodexUnsupportedModelDetail(detail)
|
|
226
|
+
=== normalizeCodexUnsupportedModelDetail(expected);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function shouldRetryCodexPoolAccountModel400(
|
|
233
|
+
response: Response,
|
|
234
|
+
modelId: string,
|
|
235
|
+
signal?: AbortSignal,
|
|
236
|
+
): Promise<boolean> {
|
|
237
|
+
if (response.status !== 400) return false;
|
|
238
|
+
try {
|
|
239
|
+
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
240
|
+
return body.displaySafe
|
|
241
|
+
&& !body.truncated
|
|
242
|
+
&& isAllowListedCodexAccountModel400(response.status, body.text, modelId);
|
|
243
|
+
} catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
|
|
249
|
+
function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
|
|
250
|
+
return response.status === 429 || response.status === 402;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface CodexPoolAccountRetryArgs {
|
|
254
|
+
req: Request;
|
|
255
|
+
config: OcxConfig;
|
|
256
|
+
route: { providerName: string; modelId: string; provider: OcxProviderConfig };
|
|
257
|
+
parsed: OcxParsedRequest;
|
|
258
|
+
logCtx: RequestLogContext;
|
|
259
|
+
options: {
|
|
260
|
+
abortSignal?: AbortSignal;
|
|
261
|
+
onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
|
|
262
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
263
|
+
// Narrowed subset of HandleResponsesOptions: the retry rebuilds the adapter, so it
|
|
264
|
+
// needs the inbound scope or the retry could land on a different wire than the
|
|
265
|
+
// first attempt.
|
|
266
|
+
inboundWire?: InboundWire;
|
|
267
|
+
translatorBudget: TranslatorBudget;
|
|
268
|
+
};
|
|
269
|
+
firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
270
|
+
firstResponse: Response;
|
|
271
|
+
outcomeStatus: number;
|
|
272
|
+
upstream: AbortController;
|
|
273
|
+
connectMs: number;
|
|
274
|
+
passthroughEstimate?: number;
|
|
275
|
+
stream: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type CodexPoolAccountRetryResult =
|
|
279
|
+
| {
|
|
280
|
+
kind: "retried";
|
|
281
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
282
|
+
request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
|
|
283
|
+
upstreamResponse: Response;
|
|
284
|
+
selectedForwardHeaders: Headers;
|
|
285
|
+
}
|
|
286
|
+
| { kind: "no-alternate" }
|
|
287
|
+
| {
|
|
288
|
+
kind: "transport";
|
|
289
|
+
error: unknown;
|
|
290
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function codexQuotaOutcomeMeta(response: Response): {
|
|
294
|
+
retryAfter: string | null;
|
|
295
|
+
resetAt: string[];
|
|
296
|
+
} {
|
|
297
|
+
return {
|
|
298
|
+
retryAfter: response.headers.get("retry-after"),
|
|
299
|
+
resetAt: [
|
|
300
|
+
response.headers.get("x-codex-primary-reset-at"),
|
|
301
|
+
response.headers.get("x-codex-secondary-reset-at"),
|
|
302
|
+
response.headers.get("x-codex-tertiary-reset-at"),
|
|
303
|
+
].filter((value): value is string => !!value),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A reset timestamp describes a quota window, not an explicit instruction to
|
|
309
|
+
* stop using the whole account. A combo may therefore try a later model in the
|
|
310
|
+
* same request, while Retry-After and headerless quota failures remain blocking.
|
|
311
|
+
*/
|
|
312
|
+
function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
|
|
313
|
+
return enabled === true
|
|
314
|
+
&& (response.status === 429 || response.status === 402)
|
|
315
|
+
&& computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* One bounded alternate-account retry for Codex pool auth. Used for allow-listed
|
|
320
|
+
* model-400 and for pre-stream 429/402 quota failures (#584).
|
|
321
|
+
*/
|
|
322
|
+
async function retryCodexPoolOnAlternateAccount(
|
|
323
|
+
args: CodexPoolAccountRetryArgs,
|
|
324
|
+
): Promise<CodexPoolAccountRetryResult> {
|
|
325
|
+
const {
|
|
326
|
+
req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
|
|
327
|
+
outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
|
|
328
|
+
} = args;
|
|
329
|
+
const inboundWire = options.inboundWire ?? "responses";
|
|
330
|
+
let retryAuthCtx: CodexAuthContext | undefined;
|
|
331
|
+
try {
|
|
332
|
+
retryAuthCtx = await resolveCodexAuthContext(
|
|
333
|
+
req.headers,
|
|
334
|
+
config,
|
|
335
|
+
"pool",
|
|
336
|
+
{ excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId },
|
|
337
|
+
);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (
|
|
340
|
+
!(error instanceof CodexPoolAuthenticationError)
|
|
341
|
+
&& !(error instanceof CodexAuthContextError)
|
|
342
|
+
&& !(error instanceof CodexAccountCooldownError)
|
|
343
|
+
) throw error;
|
|
344
|
+
}
|
|
345
|
+
if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
|
|
346
|
+
return { kind: "no-alternate" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
|
|
350
|
+
if (outcomeStatus === 429 || outcomeStatus === 402) {
|
|
351
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
352
|
+
applyAccountQuotaFromUpstreamHeaders(
|
|
353
|
+
firstAuthCtx.accountId,
|
|
354
|
+
firstResponse.headers,
|
|
355
|
+
firstAuthCtx.writerGeneration,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
|
|
359
|
+
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
360
|
+
...quotaMeta,
|
|
361
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
362
|
+
modelId: route.modelId,
|
|
363
|
+
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
364
|
+
probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
|
|
365
|
+
writerGeneration: firstAuthCtx.writerGeneration,
|
|
366
|
+
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
367
|
+
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
372
|
+
const retryProvider = applyCodexAuthContextToProvider(
|
|
373
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
374
|
+
retryAuthCtx,
|
|
375
|
+
"pool",
|
|
376
|
+
);
|
|
377
|
+
const retryAdapter = resolveAdapter(
|
|
378
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider, inboundWire),
|
|
379
|
+
config.cacheRetention,
|
|
380
|
+
);
|
|
381
|
+
const request = await retryAdapter.buildRequest(parsed, {
|
|
382
|
+
headers: retryHeaders,
|
|
383
|
+
translatorBudget: options.translatorBudget,
|
|
384
|
+
});
|
|
385
|
+
recordAdapterReasoning(logCtx, request);
|
|
386
|
+
|
|
387
|
+
await firstResponse.body?.cancel().catch(() => undefined);
|
|
388
|
+
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
389
|
+
route.provider = retryProvider;
|
|
390
|
+
logCtx.provider = formatCodexProviderForLog(
|
|
391
|
+
route.providerName,
|
|
392
|
+
retryAuthCtx.accountId,
|
|
393
|
+
config,
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
397
|
+
try {
|
|
398
|
+
const upstreamResponse = await fetchWithHeaderTimeout(
|
|
399
|
+
request.url,
|
|
400
|
+
{
|
|
401
|
+
method: request.method,
|
|
402
|
+
headers: request.headers,
|
|
403
|
+
body: request.body,
|
|
404
|
+
},
|
|
405
|
+
upstream.signal,
|
|
406
|
+
connectMs,
|
|
407
|
+
stream,
|
|
408
|
+
providerFetch(route.provider),
|
|
409
|
+
);
|
|
410
|
+
return {
|
|
411
|
+
kind: "retried",
|
|
412
|
+
authCtx: retryAuthCtx,
|
|
413
|
+
request,
|
|
414
|
+
upstreamResponse,
|
|
415
|
+
selectedForwardHeaders: retryHeaders,
|
|
416
|
+
};
|
|
417
|
+
} catch (error) {
|
|
418
|
+
// Attribute the transport failure to the alternate account (already selected).
|
|
419
|
+
return { kind: "transport", error, authCtx: retryAuthCtx };
|
|
420
|
+
} finally {
|
|
421
|
+
request.releaseBodyObservation?.();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
export function codexForwardTerminalOutcomeRecorder(
|
|
428
|
+
config: OcxConfig,
|
|
429
|
+
authCtx: CodexAuthContext,
|
|
430
|
+
provider: OcxProviderConfig,
|
|
431
|
+
modelId?: string,
|
|
432
|
+
logCtx?: RequestLogContext,
|
|
433
|
+
threadId?: string | null,
|
|
434
|
+
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
435
|
+
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
436
|
+
return (status, httpStatusOverride) => {
|
|
437
|
+
if (status === "incomplete") {
|
|
438
|
+
// Normal limit/content-filter/stall terminal — the account served the
|
|
439
|
+
// request. Don't penalize account health; record success to clear any
|
|
440
|
+
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
441
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
442
|
+
threadId,
|
|
443
|
+
modelId,
|
|
444
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
445
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
446
|
+
writerGeneration: authCtx.writerGeneration,
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// status === "completed" or "failed": use the semantic HTTP status derived
|
|
451
|
+
// from the terminal SSE error payload (httpStatusFromTerminalError in
|
|
452
|
+
// request-log inspection) instead of collapsing every non-completed terminal
|
|
453
|
+
// to 502. A 400 invalid_request_error must not soft-avoid the account or
|
|
454
|
+
// rebind threads — only genuine transport/5xx failures should trigger
|
|
455
|
+
// transient health recording.
|
|
456
|
+
// httpStatusOverride: the combo WS path inspects SSE payloads into the parent
|
|
457
|
+
// logCtx, but this recorder closes over the child logCtx. The caller passes
|
|
458
|
+
// the parent's terminalHttpStatus so the semantic status is not lost.
|
|
459
|
+
const outcome = status === "completed"
|
|
460
|
+
? 200
|
|
461
|
+
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
462
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
463
|
+
threadId,
|
|
464
|
+
modelId,
|
|
465
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
466
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
467
|
+
writerGeneration: authCtx.writerGeneration,
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
export function decodeRequestErrorResponse(err: unknown, label: string): Response {
|
|
475
|
+
if (isTranslatorBudgetExceededError(err)) {
|
|
476
|
+
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
|
|
477
|
+
code: "translation_buffer_limit",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (err instanceof UnsupportedContentEncodingError) {
|
|
481
|
+
return formatErrorResponse(415, "invalid_request_error", err.message);
|
|
482
|
+
}
|
|
483
|
+
if (err instanceof DecompressedBodyTooLargeError) {
|
|
484
|
+
return formatErrorResponse(413, "invalid_request_error", err.message);
|
|
485
|
+
}
|
|
486
|
+
console.warn(`[${label}] request body decode/parse failed: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
|
|
487
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
export function comboUnavailableResponse(message: string): Response {
|
|
493
|
+
return new Response(
|
|
494
|
+
JSON.stringify({
|
|
495
|
+
error: { message, type: "server_error", code: "combo_unavailable" },
|
|
496
|
+
}),
|
|
497
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
export interface ConsumedComboFailure {
|
|
504
|
+
response: Response;
|
|
505
|
+
classificationText: string;
|
|
506
|
+
/** Structured upstream `error.code` when present in the failure body. */
|
|
507
|
+
upstreamCode?: string;
|
|
508
|
+
/** Valid numeric/date value used only for cooldown calculation. */
|
|
509
|
+
retryAfter?: string;
|
|
510
|
+
/** Reserved for 040 usage attribution without adding another body read. */
|
|
511
|
+
usage?: OcxUsage;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
export interface HandleResponsesOptions {
|
|
517
|
+
turnAdmissionLease?: AdmissionLease;
|
|
518
|
+
forceEmptyResponseId?: boolean;
|
|
519
|
+
abortSignal?: AbortSignal;
|
|
520
|
+
/** One-shot TTFT callback: first non-empty model output observed (WP4). */
|
|
521
|
+
onFirstOutput?: () => void;
|
|
522
|
+
onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
|
|
523
|
+
recordTerminalOutcomes?: boolean;
|
|
524
|
+
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
|
|
525
|
+
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
526
|
+
onNativePassthroughCancel?: () => void;
|
|
527
|
+
/**
|
|
528
|
+
* When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
|
|
529
|
+
* (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
|
|
530
|
+
*/
|
|
531
|
+
promptCacheKeyIsSharedCohort?: boolean;
|
|
532
|
+
/**
|
|
533
|
+
* Wire protocol the ORIGINAL client spoke. The Chat and Anthropic surfaces translate
|
|
534
|
+
* their body into a Responses shape and replay through this function, so without an
|
|
535
|
+
* explicit value the replay would look like a native Responses request and an
|
|
536
|
+
* inbound-scoped registry wire default would fire for a client that never asked for
|
|
537
|
+
* it. Omitted means a genuine Responses inbound.
|
|
538
|
+
*/
|
|
539
|
+
inboundWire?: InboundWire;
|
|
540
|
+
/** Internal recursion guard; callers outside this module must not set it. */
|
|
541
|
+
comboAttempt?: boolean;
|
|
542
|
+
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
|
|
543
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
544
|
+
/** 030-owned handoff when a child consumed the original failure under bounds. */
|
|
545
|
+
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
|
|
546
|
+
/** Caller-owned for Chat/Claude replay; omitted only at genuine Responses ingress. */
|
|
547
|
+
translatorBudget?: TranslatorBudget;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
export function clientCancelledResponse(): Response {
|
|
553
|
+
return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
export function sanitizedRetryAfter(value: string | null, now: number): string | undefined {
|
|
559
|
+
const trimmed = value?.trim();
|
|
560
|
+
if (!trimmed || trimmed.length > 128) return undefined;
|
|
561
|
+
return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
export async function consumeComboFailure(
|
|
567
|
+
response: Response,
|
|
568
|
+
signal?: AbortSignal,
|
|
569
|
+
now = Date.now(),
|
|
570
|
+
): Promise<ConsumedComboFailure> {
|
|
571
|
+
const fallback = `Provider error ${response.status}`;
|
|
572
|
+
let classificationText = fallback;
|
|
573
|
+
let usage: OcxUsage | undefined;
|
|
574
|
+
let upstreamCode: string | undefined;
|
|
575
|
+
try {
|
|
576
|
+
const body = await readBoundedResponseBody(response, { signal });
|
|
577
|
+
usage = usageFromComboFailureText(body.text);
|
|
578
|
+
if (body.displaySafe) {
|
|
579
|
+
const safeText = redactSecretString(body.text).slice(0, 500);
|
|
580
|
+
if (safeText) classificationText = safeText;
|
|
581
|
+
try {
|
|
582
|
+
const parsed = JSON.parse(body.text) as { error?: { code?: unknown } | string };
|
|
583
|
+
const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.code : undefined;
|
|
584
|
+
if (typeof nested === "string" && nested.length > 0) upstreamCode = nested;
|
|
585
|
+
} catch {
|
|
586
|
+
/* non-JSON upstream body — message-only classification */
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (signal?.aborted) throw error;
|
|
591
|
+
classificationText = fallback;
|
|
592
|
+
}
|
|
593
|
+
const message = classificationText === fallback
|
|
594
|
+
? fallback
|
|
595
|
+
: `${fallback}: ${classificationText}`;
|
|
596
|
+
const upstreamRetryAfter = response.headers.get("retry-after");
|
|
597
|
+
// Client response may get the synthetic "2" fallback; cooldown metadata must not —
|
|
598
|
+
// otherwise coolComboTarget treats it as a 2s cooldown instead of the 60s default.
|
|
599
|
+
const clientRetryAfter = resolveClientRetryAfter({
|
|
600
|
+
status: response.status,
|
|
601
|
+
message,
|
|
602
|
+
upstreamRetryAfter,
|
|
603
|
+
now,
|
|
604
|
+
});
|
|
605
|
+
const cooldownRetryAfter = resolveClientRetryAfter({
|
|
606
|
+
status: response.status,
|
|
607
|
+
message,
|
|
608
|
+
upstreamRetryAfter,
|
|
609
|
+
now,
|
|
610
|
+
includeDefault: false,
|
|
611
|
+
});
|
|
612
|
+
return {
|
|
613
|
+
response: formatErrorResponse(response.status, "upstream_error", message, {
|
|
614
|
+
...(upstreamCode !== undefined ? { code: upstreamCode } : {}),
|
|
615
|
+
...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
|
|
616
|
+
}),
|
|
617
|
+
classificationText,
|
|
618
|
+
...(upstreamCode !== undefined ? { upstreamCode } : {}),
|
|
619
|
+
...(cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
|
|
620
|
+
...(usage ? { usage } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
export function usageFromComboFailureText(text: string): OcxUsage | undefined {
|
|
627
|
+
try {
|
|
628
|
+
const payload = JSON.parse(text) as Record<string, unknown>;
|
|
629
|
+
const nested = payload.response;
|
|
630
|
+
const source = nested && typeof nested === "object" && !Array.isArray(nested)
|
|
631
|
+
? nested as Record<string, unknown>
|
|
632
|
+
: payload;
|
|
633
|
+
return usageFromResponsesPayload(source.usage);
|
|
634
|
+
} catch {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
export function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
|
|
642
|
+
type Pending =
|
|
643
|
+
| { kind: "terminal"; status: ResponsesTerminalStatus }
|
|
644
|
+
| { kind: "cancel" };
|
|
645
|
+
let state: "pending" | "committed" | "discarded" = "pending";
|
|
646
|
+
let pending: Pending | undefined;
|
|
647
|
+
let accepted = false;
|
|
648
|
+
const publish = (value: Pending): void => {
|
|
649
|
+
if (value.kind === "terminal") options.onNativePassthroughTerminal?.(value.status);
|
|
650
|
+
else options.onNativePassthroughCancel?.();
|
|
651
|
+
};
|
|
652
|
+
const receive = (value: Pending): void => {
|
|
653
|
+
if (state === "discarded" || accepted) return;
|
|
654
|
+
accepted = true;
|
|
655
|
+
if (state === "committed") return publish(value);
|
|
656
|
+
pending ??= value;
|
|
657
|
+
};
|
|
658
|
+
return {
|
|
659
|
+
onTerminal: (status: ResponsesTerminalStatus) => receive({ kind: "terminal", status }),
|
|
660
|
+
onCancel: () => receive({ kind: "cancel" }),
|
|
661
|
+
commit: () => {
|
|
662
|
+
if (state !== "pending") return;
|
|
663
|
+
state = "committed";
|
|
664
|
+
if (pending) publish(pending);
|
|
665
|
+
pending = undefined;
|
|
666
|
+
},
|
|
667
|
+
discard: () => {
|
|
668
|
+
state = "discarded";
|
|
669
|
+
pending = undefined;
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
677
|
+
const childHeaders = new Headers(parentHeaders);
|
|
678
|
+
// Combo children re-serialize already-decoded JSON. Keeping transport metadata from
|
|
679
|
+
// the parent would make the child decoder treat plain JSON as compressed bytes.
|
|
680
|
+
childHeaders.delete("content-length");
|
|
681
|
+
childHeaders.delete("content-encoding");
|
|
682
|
+
return childHeaders;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
|
|
686
|
+
"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
|
+
|
|
688
|
+
function unreadableEncryptedAgentTaskResponse(): Response {
|
|
689
|
+
return new Response(
|
|
690
|
+
JSON.stringify({
|
|
691
|
+
error: {
|
|
692
|
+
message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE,
|
|
693
|
+
type: "invalid_request_error",
|
|
694
|
+
code: "unreadable_encrypted_agent_task",
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
type ResponsesAuthResolution =
|
|
702
|
+
| { ok: true; authCtx: CodexAuthContext; headers: Headers }
|
|
703
|
+
| { ok: false; response: Response };
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Resolve Codex auth for a route. On unusable contexts, releases any probe lease
|
|
707
|
+
* before returning the 401 (nothing reaches upstream).
|
|
708
|
+
*/
|
|
709
|
+
async function resolveResponsesCodexAuth(
|
|
710
|
+
req: Request,
|
|
711
|
+
config: OcxConfig,
|
|
712
|
+
route: RouteResult,
|
|
713
|
+
options: HandleResponsesOptions,
|
|
714
|
+
): Promise<ResponsesAuthResolution> {
|
|
715
|
+
try {
|
|
716
|
+
if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
|
|
717
|
+
let authCtx: CodexAuthContext;
|
|
718
|
+
if (route.codexAccountMode) {
|
|
719
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId });
|
|
720
|
+
options.onCodexAuthContextResolved?.(authCtx);
|
|
721
|
+
} else {
|
|
722
|
+
authCtx = { kind: "main", accountId: null };
|
|
723
|
+
options.onCodexAuthContextResolved?.(undefined);
|
|
724
|
+
}
|
|
725
|
+
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
726
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
727
|
+
return {
|
|
728
|
+
ok: false,
|
|
729
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
authCtx,
|
|
735
|
+
headers: headersForCodexAuthContext(req.headers, authCtx),
|
|
736
|
+
};
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (err instanceof CodexAccountCooldownError) {
|
|
739
|
+
return { ok: false, response: cooldownErrorResponse(err) };
|
|
740
|
+
}
|
|
741
|
+
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
742
|
+
return {
|
|
743
|
+
ok: false,
|
|
744
|
+
response: formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (err instanceof CodexAuthContextError) {
|
|
748
|
+
const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config);
|
|
749
|
+
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if (err instanceof CodexPoolAuthenticationError) {
|
|
756
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
757
|
+
}
|
|
758
|
+
if (err instanceof CodexDirectAuthenticationError) {
|
|
759
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
760
|
+
}
|
|
761
|
+
if (err instanceof ForwardAdmissionCredentialError) {
|
|
762
|
+
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
763
|
+
}
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Apply every route-dependent request mutation against the final selected route.
|
|
770
|
+
* Must run only after subagent fallback has settled the model/provider.
|
|
771
|
+
*/
|
|
772
|
+
async function applyFinalRouteRequestNormalization(args: {
|
|
773
|
+
parsed: OcxParsedRequest;
|
|
774
|
+
route: RouteResult;
|
|
775
|
+
config: OcxConfig;
|
|
776
|
+
req: Request;
|
|
777
|
+
logCtx: RequestLogContext;
|
|
778
|
+
inboundWire: InboundWire;
|
|
779
|
+
}): Promise<void> {
|
|
780
|
+
const { parsed, route, config, req, logCtx, inboundWire } = args;
|
|
781
|
+
|
|
782
|
+
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace.
|
|
783
|
+
if (route.modelId !== parsed.modelId) {
|
|
784
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
785
|
+
(parsed._rawBody as { model?: string }).model = route.modelId;
|
|
786
|
+
}
|
|
787
|
+
parsed.modelId = route.modelId;
|
|
788
|
+
}
|
|
789
|
+
// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
|
|
790
|
+
// this request will actually use (#404).
|
|
791
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
792
|
+
logCtx.model = route.modelId;
|
|
793
|
+
logCtx.provider = route.providerName;
|
|
794
|
+
logCtx.providerAdapter = route.provider.adapter;
|
|
795
|
+
|
|
796
|
+
// Final selected model before virtual wire-model rewriting (Pro aliases).
|
|
797
|
+
const finalSelectedModelId = route.modelId;
|
|
798
|
+
|
|
799
|
+
// Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro".
|
|
800
|
+
applyOpenAiVirtualModel(parsed, route, logCtx);
|
|
801
|
+
|
|
802
|
+
// Fast mode override for OpenAI-routed models.
|
|
803
|
+
if (config.fastMode !== undefined && route.provider.adapter === "openai-responses") {
|
|
804
|
+
const tier = config.fastMode ? "priority" : undefined;
|
|
805
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
806
|
+
if (tier) (parsed._rawBody as Record<string, unknown>).service_tier = tier;
|
|
807
|
+
else delete (parsed._rawBody as Record<string, unknown>).service_tier;
|
|
808
|
+
}
|
|
809
|
+
parsed.options.serviceTier = tier;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
{
|
|
813
|
+
const guidance = await multiAgentGuidanceText(parsed, {
|
|
814
|
+
multiAgentGuidanceEnabled: config.multiAgentGuidanceEnabled,
|
|
815
|
+
injectionModel: config.injectionModel,
|
|
816
|
+
injectionEffort: config.injectionEffort,
|
|
817
|
+
subagentModels: config.subagentModels,
|
|
818
|
+
subagentModelFallback: config.subagentModelFallback,
|
|
819
|
+
injectionPrompt: config.injectionPrompt,
|
|
820
|
+
});
|
|
821
|
+
if (guidance) {
|
|
822
|
+
injectDeveloperMessage(parsed, guidance);
|
|
823
|
+
if (isInjectionDebugEnabled()) {
|
|
824
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: multi-agent guidance injected (surface=${collabSurface(parsed)}, guidanceEnabled=${multiAgentGuidanceEnabled(config)}, ${guidance.length} chars)`);
|
|
825
|
+
}
|
|
826
|
+
} else if (isInjectionDebugEnabled() && collabSurface(parsed) !== null) {
|
|
827
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: collab surface=${collabSurface(parsed)}, guidance silent (effort=${parsed.options.reasoning ?? "unset"}, injectionModel=${config.injectionModel ?? "unset"})`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
{
|
|
832
|
+
const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy");
|
|
833
|
+
const surface = collabSurface(parsed);
|
|
834
|
+
if (effortCapAppliesTo(surface, req.headers, config, parsed._compactionRequest === true)) {
|
|
835
|
+
const capped = applyEffortCap(parsed, req.headers, config, supportedLadderFor(route));
|
|
836
|
+
if (capped) {
|
|
837
|
+
logCtx.requestedEffort = `${capped.from}->${capped.to}`;
|
|
838
|
+
if (isInjectionDebugEnabled()) {
|
|
839
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: effort cap applied (${capped.from} -> ${capped.to}, ${capped.subagent ? "sub-agent" : "main"} turn)`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
} else if (isInjectionDebugEnabled() && (config.effortCap || config.subagentEffortCap)) {
|
|
843
|
+
injectionDebugLog(`[opencodex] ${route.modelId}: effort cap skipped (surface=${surface ?? "none"}, v2 feature only)`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
{
|
|
848
|
+
const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog");
|
|
849
|
+
const clamped = shouldApplyNativeEffortClamp(route.providerName, route.provider, finalSelectedModelId)
|
|
850
|
+
? nativeEffortClamp(route.modelId, parsed.options.reasoning)
|
|
851
|
+
: null;
|
|
852
|
+
if (clamped) {
|
|
853
|
+
parsed.options.reasoning = clamped;
|
|
854
|
+
const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
|
|
855
|
+
if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = clamped;
|
|
856
|
+
logCtx.requestedEffort = `${logCtx.requestedEffort ?? "max"}->${clamped}`;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
recordAttemptRequestedEffort(logCtx);
|
|
860
|
+
logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
|
|
861
|
+
route.modelId,
|
|
862
|
+
logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
export async function handleComboResponses(
|
|
869
|
+
req: Request,
|
|
870
|
+
rawBody: unknown,
|
|
871
|
+
comboId: string,
|
|
872
|
+
config: OcxConfig,
|
|
873
|
+
logCtx: RequestLogContext,
|
|
874
|
+
options: HandleResponsesOptions,
|
|
875
|
+
): Promise<Response> {
|
|
876
|
+
const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string"
|
|
877
|
+
? (rawBody as { model: string }).model
|
|
878
|
+
: `combo/${comboId}`;
|
|
879
|
+
Object.assign(logCtx, {
|
|
880
|
+
requestedModel,
|
|
881
|
+
model: requestedModel,
|
|
882
|
+
provider: "combo",
|
|
883
|
+
comboId,
|
|
884
|
+
});
|
|
885
|
+
const combo = getCombo(config, comboId);
|
|
886
|
+
if (!combo) {
|
|
887
|
+
return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
|
|
888
|
+
}
|
|
889
|
+
const adoptFailedChildLog = (childLog: RequestLogContext): void => {
|
|
890
|
+
// Attempts remain the complete physical history; the logical row mirrors the most recent
|
|
891
|
+
// failed target so an exhausted combo still has useful top-level reasoning diagnostics.
|
|
892
|
+
Object.assign(logCtx, childLog, {
|
|
893
|
+
requestedModel,
|
|
894
|
+
model: requestedModel,
|
|
895
|
+
provider: "combo",
|
|
896
|
+
comboId,
|
|
897
|
+
attempts: logCtx.attempts,
|
|
898
|
+
activeAttempt: undefined,
|
|
899
|
+
activeAttemptStartedAt: undefined,
|
|
900
|
+
});
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
904
|
+
(rawBody as { input?: unknown } | undefined)?.input,
|
|
905
|
+
);
|
|
906
|
+
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
|
|
907
|
+
const provider = config.providers[target.provider];
|
|
908
|
+
if (!provider || provider.disabled === true) return false;
|
|
909
|
+
try {
|
|
910
|
+
const route = routeModel(config, `${target.provider}/${target.model}`);
|
|
911
|
+
return isCanonicalOpenAiForwardProvider(route.provider);
|
|
912
|
+
} catch {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
|
|
917
|
+
!unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
|
|
918
|
+
|
|
919
|
+
if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) {
|
|
920
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const initialNow = Date.now();
|
|
924
|
+
let pick = pickComboTarget(config, comboId, {
|
|
925
|
+
eligible: target => payloadEligible(target)
|
|
926
|
+
&& !isComboTargetInCooldown(comboId, target, initialNow),
|
|
927
|
+
});
|
|
928
|
+
if (!pick) {
|
|
929
|
+
return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
let lastFailure: Response | null = null;
|
|
933
|
+
while (pick) {
|
|
934
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
935
|
+
const childLog: RequestLogContext = {
|
|
936
|
+
model: pick.target.model,
|
|
937
|
+
provider: pick.target.provider,
|
|
938
|
+
...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
|
|
939
|
+
...(logCtx.surface ? { surface: logCtx.surface } : {}),
|
|
940
|
+
};
|
|
941
|
+
const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
942
|
+
const childBody = concreteComboRequestBody(
|
|
943
|
+
rawBody,
|
|
944
|
+
pick.target,
|
|
945
|
+
comboDefaultEffort(config, comboId),
|
|
946
|
+
supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
|
|
947
|
+
);
|
|
948
|
+
const childHeaders = buildComboChildHeaders(req.headers);
|
|
949
|
+
const childRequest = new Request(req.url, {
|
|
950
|
+
method: req.method,
|
|
951
|
+
headers: childHeaders,
|
|
952
|
+
body: JSON.stringify(childBody),
|
|
953
|
+
});
|
|
954
|
+
let resolvedAuth: CodexAuthContext | undefined;
|
|
955
|
+
let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
|
|
956
|
+
const started = Date.now();
|
|
957
|
+
const attempt = beginRequestAttempt(
|
|
958
|
+
(logCtx.attempts?.length ?? 0) + 1,
|
|
959
|
+
pick.target.provider,
|
|
960
|
+
pick.target.model,
|
|
961
|
+
config.providers[pick.target.provider]!.adapter,
|
|
962
|
+
);
|
|
963
|
+
childLog.activeAttempt = attempt;
|
|
964
|
+
let attemptRetained = false;
|
|
965
|
+
const retainCancelledAttempt = (): void => {
|
|
966
|
+
if (attemptRetained) return;
|
|
967
|
+
sealRequestAttemptIdentity(
|
|
968
|
+
attempt,
|
|
969
|
+
childLog.provider,
|
|
970
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
971
|
+
);
|
|
972
|
+
finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
|
|
973
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
974
|
+
attemptRetained = true;
|
|
975
|
+
};
|
|
976
|
+
let consumedChildFailure: ConsumedComboFailure | undefined;
|
|
977
|
+
const callbackGate = createChildPassthroughCallbackGate(options);
|
|
978
|
+
let response: Response;
|
|
979
|
+
try {
|
|
980
|
+
const currentTargetProvider = pick.target.provider;
|
|
981
|
+
const deferCodexResetDerivedCooldown = combo.strategy === "failover"
|
|
982
|
+
&& combo.targets.slice(pick.targetIndex + 1).some(target =>
|
|
983
|
+
target.provider === currentTargetProvider
|
|
984
|
+
&& payloadEligible(target)
|
|
985
|
+
&& !isComboTargetInCooldown(comboId, target),
|
|
986
|
+
);
|
|
987
|
+
response = await handleResponses(childRequest, config, childLog, {
|
|
988
|
+
...options,
|
|
989
|
+
comboAttempt: true,
|
|
990
|
+
deferCodexResetDerivedCooldown,
|
|
991
|
+
// Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
|
|
992
|
+
// Object.assign(logCtx, childLog) would overwrite the request-relative value).
|
|
993
|
+
onFirstOutput: () => {
|
|
994
|
+
if (attempt.firstOutputMs === undefined) {
|
|
995
|
+
attempt.firstOutputMs = Math.max(0, Date.now() - started);
|
|
996
|
+
}
|
|
997
|
+
options.onFirstOutput?.();
|
|
998
|
+
},
|
|
999
|
+
onCodexAuthContextResolved: value => { resolvedAuth = value; },
|
|
1000
|
+
setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
|
|
1001
|
+
onConsumedComboFailure: value => { consumedChildFailure = value; },
|
|
1002
|
+
onNativePassthroughTerminal: callbackGate.onTerminal,
|
|
1003
|
+
onNativePassthroughCancel: callbackGate.onCancel,
|
|
1004
|
+
});
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
callbackGate.discard();
|
|
1007
|
+
if (options.abortSignal?.aborted) {
|
|
1008
|
+
retainCancelledAttempt();
|
|
1009
|
+
return clientCancelledResponse();
|
|
1010
|
+
}
|
|
1011
|
+
throw error;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (options.abortSignal?.aborted) {
|
|
1015
|
+
callbackGate.discard();
|
|
1016
|
+
retainCancelledAttempt();
|
|
1017
|
+
return clientCancelledResponse();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (response.ok) {
|
|
1021
|
+
sealRequestAttemptIdentity(
|
|
1022
|
+
attempt,
|
|
1023
|
+
childLog.provider,
|
|
1024
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
1025
|
+
);
|
|
1026
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
1027
|
+
attemptRetained = true;
|
|
1028
|
+
noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
|
|
1029
|
+
Object.assign(logCtx, childLog, {
|
|
1030
|
+
requestedModel,
|
|
1031
|
+
model: requestedModel,
|
|
1032
|
+
provider: "combo",
|
|
1033
|
+
comboId,
|
|
1034
|
+
attempts: logCtx.attempts,
|
|
1035
|
+
activeAttempt: attempt,
|
|
1036
|
+
activeAttemptStartedAt: started,
|
|
1037
|
+
resolvedModel: childLog.resolvedModel ?? childLog.model,
|
|
1038
|
+
});
|
|
1039
|
+
options.onCodexAuthContextResolved?.(resolvedAuth);
|
|
1040
|
+
options.setTerminalOutcomeRecorder?.(terminalRecorder);
|
|
1041
|
+
callbackGate.commit();
|
|
1042
|
+
return response;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
callbackGate.discard();
|
|
1046
|
+
if (response.status === 499) {
|
|
1047
|
+
retainCancelledAttempt();
|
|
1048
|
+
return clientCancelledResponse();
|
|
1049
|
+
}
|
|
1050
|
+
let failure: ConsumedComboFailure;
|
|
1051
|
+
try {
|
|
1052
|
+
failure = consumedChildFailure
|
|
1053
|
+
?? await consumeComboFailure(response, options.abortSignal);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (options.abortSignal?.aborted) {
|
|
1056
|
+
retainCancelledAttempt();
|
|
1057
|
+
return clientCancelledResponse();
|
|
1058
|
+
}
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
if (options.abortSignal?.aborted) {
|
|
1062
|
+
retainCancelledAttempt();
|
|
1063
|
+
return clientCancelledResponse();
|
|
1064
|
+
}
|
|
1065
|
+
sealRequestAttemptIdentity(
|
|
1066
|
+
attempt,
|
|
1067
|
+
childLog.provider,
|
|
1068
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
1069
|
+
);
|
|
1070
|
+
finishRequestAttempt(
|
|
1071
|
+
attempt,
|
|
1072
|
+
response.status,
|
|
1073
|
+
Date.now() - started,
|
|
1074
|
+
failure.usage,
|
|
1075
|
+
);
|
|
1076
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
1077
|
+
attemptRetained = true;
|
|
1078
|
+
lastFailure = failure.response;
|
|
1079
|
+
if (comboFailureDecision(failure.response.status, failure.classificationText, {
|
|
1080
|
+
code: failure.upstreamCode,
|
|
1081
|
+
}) === "stop") {
|
|
1082
|
+
adoptFailedChildLog(childLog);
|
|
1083
|
+
return lastFailure;
|
|
1084
|
+
}
|
|
1085
|
+
console.warn(
|
|
1086
|
+
`[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`,
|
|
1087
|
+
);
|
|
1088
|
+
const nextPick = advanceComboAfterFailure(config, pick, {
|
|
1089
|
+
retryAfter: failure.retryAfter,
|
|
1090
|
+
now: Date.now(),
|
|
1091
|
+
eligible: payloadEligible,
|
|
1092
|
+
});
|
|
1093
|
+
if (!nextPick) adoptFailedChildLog(childLog);
|
|
1094
|
+
pick = nextPick;
|
|
1095
|
+
}
|
|
1096
|
+
return lastFailure!;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
function finalizeOwnedTranslatorBudget(response: Response, budget: TranslatorBudget): Response {
|
|
1102
|
+
if (!response.body) {
|
|
1103
|
+
budget.dispose();
|
|
1104
|
+
return response;
|
|
1105
|
+
}
|
|
1106
|
+
const reader = response.body.getReader();
|
|
1107
|
+
let finalized = false;
|
|
1108
|
+
const finalize = () => {
|
|
1109
|
+
if (finalized) return;
|
|
1110
|
+
finalized = true;
|
|
1111
|
+
budget.dispose();
|
|
1112
|
+
};
|
|
1113
|
+
const body = new ReadableStream<Uint8Array>({
|
|
1114
|
+
async pull(controller) {
|
|
1115
|
+
try {
|
|
1116
|
+
const result = await reader.read();
|
|
1117
|
+
if (result.done) {
|
|
1118
|
+
finalize();
|
|
1119
|
+
controller.close();
|
|
1120
|
+
} else {
|
|
1121
|
+
controller.enqueue(result.value);
|
|
1122
|
+
}
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
finalize();
|
|
1125
|
+
controller.error(error);
|
|
1126
|
+
}
|
|
1127
|
+
},
|
|
1128
|
+
async cancel(reason) {
|
|
1129
|
+
try { await reader.cancel(reason); } finally { finalize(); }
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
const finalizedResponse = new Response(body, {
|
|
1133
|
+
status: response.status,
|
|
1134
|
+
statusText: response.statusText,
|
|
1135
|
+
headers: response.headers,
|
|
1136
|
+
});
|
|
1137
|
+
if (isNativePassthroughSseResponse(response)) {
|
|
1138
|
+
markNativePassthroughSseResponse(finalizedResponse);
|
|
1139
|
+
}
|
|
1140
|
+
if (isEagerRelaySseResponse(response)) {
|
|
1141
|
+
markEagerRelaySseResponse(finalizedResponse);
|
|
1142
|
+
}
|
|
1143
|
+
return finalizedResponse;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
export async function handleResponses(
|
|
1147
|
+
req: Request,
|
|
1148
|
+
config: OcxConfig,
|
|
1149
|
+
logCtx: RequestLogContext,
|
|
1150
|
+
options: HandleResponsesOptions = {},
|
|
1151
|
+
): Promise<Response> {
|
|
1152
|
+
const ownsBudget = options.translatorBudget === undefined;
|
|
1153
|
+
const translatorBudget = options.translatorBudget ?? createTranslatorBudget();
|
|
1154
|
+
try {
|
|
1155
|
+
const response = await handleResponsesInner(req, config, logCtx, { ...options, translatorBudget });
|
|
1156
|
+
return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response;
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
if (ownsBudget) translatorBudget.dispose();
|
|
1159
|
+
throw error;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function handleResponsesInner(
|
|
1164
|
+
req: Request,
|
|
1165
|
+
config: OcxConfig,
|
|
1166
|
+
logCtx: RequestLogContext,
|
|
1167
|
+
options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
|
|
1168
|
+
): Promise<Response> {
|
|
1169
|
+
// The Chat and Anthropic surfaces replay through here with a Responses-shaped body,
|
|
1170
|
+
// so an omitted value means a genuine Responses inbound.
|
|
1171
|
+
const inboundWire = options.inboundWire ?? "responses";
|
|
1172
|
+
const translatorBudget = options.translatorBudget;
|
|
1173
|
+
let body: unknown;
|
|
1174
|
+
try {
|
|
1175
|
+
body = await readJsonRequestBody(req, translatorBudget);
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
return decodeRequestErrorResponse(err, "responses");
|
|
1178
|
+
}
|
|
1179
|
+
const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
|
|
1180
|
+
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
1181
|
+
return handleComboResponses(req, body, comboId, config, logCtx, options);
|
|
1182
|
+
}
|
|
1183
|
+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
1184
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
1185
|
+
);
|
|
1186
|
+
const originalBody = body;
|
|
1187
|
+
body = expandPreviousResponseInput(body);
|
|
1188
|
+
if (previousResponseReplayFailure(body)) {
|
|
1189
|
+
return formatErrorResponse(
|
|
1190
|
+
400,
|
|
1191
|
+
"previous_response_not_found",
|
|
1192
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const previousResponseInputExpanded = body !== originalBody;
|
|
1196
|
+
|
|
1197
|
+
// Spawn-message compatibility (both directions): agent_message task payloads ride in
|
|
1198
|
+
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
1199
|
+
// parsing so every consumer sees the payload: parseRequest (routed/translated providers read
|
|
1200
|
+
// the parsed messages) and the native passthrough (_rawBody is this same object, serialized
|
|
1201
|
+
// verbatim). Genuine backend ciphertext is left byte-identical (looksLikeBackendCiphertext).
|
|
1202
|
+
{
|
|
1203
|
+
const rewritten = sanitizeEncryptedContentInPlace(
|
|
1204
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
1205
|
+
);
|
|
1206
|
+
if (rewritten > 0)
|
|
1207
|
+
console.warn(
|
|
1208
|
+
`[opencodex] rewrote ${rewritten} plaintext encrypted_content part(s) to input_text (spawn-message compatibility)`,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
let parsed;
|
|
1213
|
+
let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
|
|
1214
|
+
try {
|
|
1215
|
+
parsed = parseRequest(body);
|
|
1216
|
+
toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget);
|
|
1217
|
+
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
1218
|
+
parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
|
|
1219
|
+
parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
|
|
1220
|
+
const clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim();
|
|
1221
|
+
if (clientThreadId) parsed._clientThreadId = clientThreadId;
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
if (isTranslatorBudgetExceededError(err)) {
|
|
1224
|
+
return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
|
|
1225
|
+
code: "translation_buffer_limit",
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1229
|
+
}
|
|
1230
|
+
// Prefer a pre-populated id (routed Claude) over Responses headers that may be
|
|
1231
|
+
// absent or synthetically injected (session_id from prompt_cache_key).
|
|
1232
|
+
if (!logCtx.conversationId) {
|
|
1233
|
+
logCtx.conversationId = conversationIdFromResponsesRequest({
|
|
1234
|
+
clientThreadId: parsed._clientThreadId,
|
|
1235
|
+
sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
|
|
1236
|
+
threadIdHeader: req.headers.get("thread-id"),
|
|
1237
|
+
cursorConversationId: parsed._cursorConversationId,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
logCtx.requestedModel = parsed.modelId;
|
|
1241
|
+
logCtx.requestedEffort = parsed.options.reasoning;
|
|
1242
|
+
logCtx.requestedServiceTier = parsed.options.serviceTier;
|
|
1243
|
+
logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
|
|
1244
|
+
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
1245
|
+
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
1246
|
+
|
|
1247
|
+
// Shadow call intercept: rewrite Codex's hard-coded helper calls
|
|
1248
|
+
// (gpt-5.4-mini on older clients, gpt-5.6-luna on 0.145.0+)
|
|
1249
|
+
const _sci = config.shadowCallIntercept;
|
|
1250
|
+
if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
|
|
1251
|
+
const _sciOriginal = parsed.modelId;
|
|
1252
|
+
parsed.modelId = _sci.model;
|
|
1253
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1254
|
+
(parsed._rawBody as { model?: string }).model = _sci.model;
|
|
1255
|
+
}
|
|
1256
|
+
// Force effort to low for shadow/helper calls (matching upstream behavior)
|
|
1257
|
+
parsed.options.reasoning = "low";
|
|
1258
|
+
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
1259
|
+
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
1260
|
+
}
|
|
1261
|
+
(logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
|
|
1262
|
+
// Helpers must not resume/append into the parent thread's Cursor conversation.
|
|
1263
|
+
parsed._cursorIsolateConversation = true;
|
|
1264
|
+
}
|
|
1265
|
+
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
1266
|
+
|
|
1267
|
+
let route: RouteResult;
|
|
1268
|
+
try {
|
|
1269
|
+
route = routeModel(config, parsed.modelId);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
1272
|
+
return comboUnavailableResponse(err.message);
|
|
1273
|
+
}
|
|
1274
|
+
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
|
|
1278
|
+
&& parsed._previousResponseInputExpanded !== true;
|
|
1279
|
+
// A canonical replay miss must not poll quota upstream before the final fail-closed decision.
|
|
1280
|
+
// Cached fallback state can still select a provider with native continuation support below.
|
|
1281
|
+
if (
|
|
1282
|
+
isThreadSpawnRequest(req.headers)
|
|
1283
|
+
&& !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
|
|
1284
|
+
) {
|
|
1285
|
+
await maybePrimeSubagentQuota(config);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
1289
|
+
let selectedForwardHeaders = req.headers;
|
|
1290
|
+
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
1291
|
+
let subagentQuotaFailureModel = parsed.modelId;
|
|
1292
|
+
|
|
1293
|
+
// Subagent fallback must settle the final model/provider BEFORE route-dependent
|
|
1294
|
+
// normalization (virtual models, effort caps, service tier, wire protocol).
|
|
1295
|
+
// Preview the preferred Codex account without acquiring a probe lease or refreshing
|
|
1296
|
+
// tokens — auth is resolved only after the final route is selected.
|
|
1297
|
+
if (isThreadSpawnRequest(req.headers) && !options.comboAttempt) {
|
|
1298
|
+
const threadId = req.headers.get("x-codex-parent-thread-id");
|
|
1299
|
+
const previewAccountId = previewCodexAccountForRequest(threadId, config);
|
|
1300
|
+
subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
|
|
1301
|
+
const fallback = applySubagentModelFallback(
|
|
1302
|
+
parsed,
|
|
1303
|
+
req.headers,
|
|
1304
|
+
config,
|
|
1305
|
+
previewAccountId,
|
|
1306
|
+
Date.now(),
|
|
1307
|
+
unreadableEncryptedAgentTask,
|
|
1308
|
+
);
|
|
1309
|
+
if (fallback) {
|
|
1310
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1311
|
+
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
1312
|
+
if (isInjectionDebugEnabled()) {
|
|
1313
|
+
injectionDebugLog(`[opencodex] subagent model fallback ${fallback.from} -> ${fallback.to}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
subagentQuotaFailureModel = fallback?.to ?? parsed.modelId;
|
|
1317
|
+
|
|
1318
|
+
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
1319
|
+
try {
|
|
1320
|
+
route = routeModel(config, fallback.to);
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
1323
|
+
return comboUnavailableResponse(err.message);
|
|
1324
|
+
}
|
|
1325
|
+
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Encrypted child tasks may only reach the canonical native backend. This check
|
|
1331
|
+
// runs against the FINAL route so native-only fallback can rescue a routed primary.
|
|
1332
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
|
|
1333
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
|
|
1337
|
+
// safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
|
|
1338
|
+
// I/O instead of stripping the id and silently forwarding a context-free delta (#702).
|
|
1339
|
+
if (
|
|
1340
|
+
hasUnexpandedPreviousResponse
|
|
1341
|
+
&& isCanonicalOpenAiForwardProvider(route.provider)
|
|
1342
|
+
) {
|
|
1343
|
+
return formatErrorResponse(
|
|
1344
|
+
400,
|
|
1345
|
+
"invalid_request_error",
|
|
1346
|
+
"OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx, inboundWire });
|
|
1351
|
+
|
|
1352
|
+
{
|
|
1353
|
+
const finalAuth = await resolveResponsesCodexAuth(req, config, route, options);
|
|
1354
|
+
if (!finalAuth.ok) return finalAuth.response;
|
|
1355
|
+
authCtx = finalAuth.authCtx;
|
|
1356
|
+
selectedForwardHeaders = finalAuth.headers;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
1360
|
+
logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
1361
|
+
// Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without
|
|
1362
|
+
// codexAccountMode still get a credential-derived scope inside the Cursor adapter.
|
|
1363
|
+
const identityScope = codexLogAccountId(authCtx);
|
|
1364
|
+
if (identityScope) parsed._cursorIdentityScope = identityScope;
|
|
1365
|
+
subagentFallbackAccountId = authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
1366
|
+
? authCtx.accountId
|
|
1367
|
+
: config.activeCodexAccountId ?? null;
|
|
1368
|
+
|
|
1369
|
+
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
1370
|
+
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
1371
|
+
const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
|
|
1372
|
+
&& route.provider.authMode === "oauth";
|
|
1373
|
+
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
1374
|
+
let anthropicPoolAccountId: string | null = null;
|
|
1375
|
+
let anthropicPoolFailovers = 0;
|
|
1376
|
+
const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
|
|
1377
|
+
? anthropicSessionKeyFromParts({
|
|
1378
|
+
sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
|
|
1379
|
+
threadIdHeader: req.headers.get("thread-id"),
|
|
1380
|
+
promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null,
|
|
1381
|
+
clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null,
|
|
1382
|
+
promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true,
|
|
1383
|
+
})
|
|
1384
|
+
: null;
|
|
1385
|
+
if (route.provider.authMode === "oauth") {
|
|
1386
|
+
try {
|
|
1387
|
+
if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
|
|
1388
|
+
const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config);
|
|
1389
|
+
if (!selection.accountId) {
|
|
1390
|
+
if (selection.reason === "all-cooled") {
|
|
1391
|
+
const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
|
|
1392
|
+
return formatErrorResponse(
|
|
1393
|
+
429,
|
|
1394
|
+
"rate_limit_error",
|
|
1395
|
+
"All Anthropic OAuth accounts are temporarily rate-limited",
|
|
1396
|
+
retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
|
|
1400
|
+
}
|
|
1401
|
+
const accessToken = await getAnthropicPoolAccessToken(selection.accountId);
|
|
1402
|
+
anthropicPoolAccountId = selection.accountId;
|
|
1403
|
+
bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId);
|
|
1404
|
+
promoteAnthropicActiveAccount(selection.accountId);
|
|
1405
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
1406
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
|
|
1407
|
+
} else {
|
|
1408
|
+
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
1409
|
+
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
1410
|
+
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
1411
|
+
if (route.providerName === "kiro") {
|
|
1412
|
+
// `{}` is intentional: this is an account-scoped request with no stored routing metadata.
|
|
1413
|
+
// Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
|
|
1414
|
+
parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
|
|
1415
|
+
}
|
|
1416
|
+
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
1417
|
+
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
1418
|
+
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
1419
|
+
const projectId = getOAuthCredentialProjectId(route.providerName);
|
|
1420
|
+
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
if (err instanceof UnsupportedOAuthProviderError) {
|
|
1425
|
+
return formatErrorResponse(
|
|
1426
|
+
400,
|
|
1427
|
+
"invalid_request_error",
|
|
1428
|
+
`${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`,
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
route.provider = resolveProviderTransport(
|
|
1435
|
+
route.providerName,
|
|
1436
|
+
route.provider,
|
|
1437
|
+
parsed.options.promptCacheKey,
|
|
1438
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
1439
|
+
);
|
|
1440
|
+
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
1441
|
+
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
1442
|
+
logCtx.providerAdapter = adapter.name;
|
|
1443
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name);
|
|
1444
|
+
const isPassthrough = "passthrough" in adapter && !!adapter.passthrough;
|
|
1445
|
+
|
|
1446
|
+
if (adapter.name === "kiro" && parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
1447
|
+
return formatErrorResponse(
|
|
1448
|
+
400,
|
|
1449
|
+
"invalid_request_error",
|
|
1450
|
+
"Kiro continuation state is missing; start a new session instead of reusing this previous_response_id.",
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
|
|
1455
|
+
const needsOpenAiVision = shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed);
|
|
1456
|
+
const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough);
|
|
1457
|
+
if (needsOpenAiVision || needsOpenAiSearch) {
|
|
1458
|
+
try {
|
|
1459
|
+
openAiSidecar = await resolveFirstUsableOpenAiSidecar(
|
|
1460
|
+
listOpenAiForwardSidecarCandidates(config),
|
|
1461
|
+
req.headers,
|
|
1462
|
+
config,
|
|
1463
|
+
);
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
// Sidecars are optional helpers for an otherwise independent routed turn.
|
|
1466
|
+
// An unavailable/cooling/expired Multi credential disables the helper; it
|
|
1467
|
+
// must not turn a valid routed-provider request into a Codex-auth failure.
|
|
1468
|
+
if (
|
|
1469
|
+
!(err instanceof CodexPoolAuthenticationError)
|
|
1470
|
+
&& !(err instanceof CodexAuthContextError)
|
|
1471
|
+
&& !(err instanceof CodexAccountCooldownError)
|
|
1472
|
+
&& !(err instanceof CodexThreadAffinityExpiredError)
|
|
1473
|
+
) throw err;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
1478
|
+
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
1479
|
+
// call, so the text-only model can reason about it.
|
|
1480
|
+
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
|
|
1481
|
+
const recordSidecarOutcome = openAiSidecar?.recordOutcome;
|
|
1482
|
+
if (visionPlan) {
|
|
1483
|
+
await describeImagesInPlace(
|
|
1484
|
+
parsed,
|
|
1485
|
+
visionPlan,
|
|
1486
|
+
openAiSidecar?.headers ?? selectedForwardHeaders,
|
|
1487
|
+
options.abortSignal,
|
|
1488
|
+
recordSidecarOutcome,
|
|
1489
|
+
translatorBudget,
|
|
1490
|
+
);
|
|
1491
|
+
} else if (modelInList(route.provider.noVisionModels, route.modelId)) {
|
|
1492
|
+
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
1493
|
+
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
1494
|
+
stripImagesInPlace(parsed, translatorBudget);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
|
|
1498
|
+
|
|
1499
|
+
const continuationStateForResponse = (
|
|
1500
|
+
emitted?: OcxProviderContinuationState,
|
|
1501
|
+
): OcxProviderContinuationState | undefined => {
|
|
1502
|
+
const cursorConversationId = parsed._cursorConversationId;
|
|
1503
|
+
const inherited = parsed._providerContinuation;
|
|
1504
|
+
if (!emitted && !inherited && !cursorConversationId) return undefined;
|
|
1505
|
+
return {
|
|
1506
|
+
...(inherited ?? {}),
|
|
1507
|
+
...(emitted ?? {}),
|
|
1508
|
+
...((inherited?.kiro || emitted?.kiro)
|
|
1509
|
+
? { kiro: { ...(inherited?.kiro ?? {}), ...(emitted?.kiro ?? {}) } }
|
|
1510
|
+
: {}),
|
|
1511
|
+
...(cursorConversationId
|
|
1512
|
+
? {
|
|
1513
|
+
cursor: {
|
|
1514
|
+
...(inherited?.cursor ?? {}),
|
|
1515
|
+
...(emitted?.cursor ?? {}),
|
|
1516
|
+
conversationId: cursorConversationId,
|
|
1517
|
+
},
|
|
1518
|
+
}
|
|
1519
|
+
: {}),
|
|
1520
|
+
};
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
// Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
|
|
1524
|
+
// one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
|
|
1525
|
+
// natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
|
|
1526
|
+
// sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
|
|
1527
|
+
// A Responses-shaped wire does not imply support for Codex's private
|
|
1528
|
+
// `compaction_trigger` item — only the canonical ChatGPT backend speaks that
|
|
1529
|
+
// contract. An API-key gateway would receive the trigger, answer with an ordinary
|
|
1530
|
+
// message, and leave Codex fataling on a missing compaction item (#422).
|
|
1531
|
+
const routedCompaction = parsed._compactionRequest === true
|
|
1532
|
+
&& !isCanonicalOpenAiForwardProvider(route.provider);
|
|
1533
|
+
if (routedCompaction) {
|
|
1534
|
+
delete parsed.context.tools;
|
|
1535
|
+
delete parsed._webSearch;
|
|
1536
|
+
delete parsed.options.toolChoice;
|
|
1537
|
+
delete parsed.options.parallelToolCalls;
|
|
1538
|
+
parsed.context.messages.push({ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
|
|
1542
|
+
const imageGenCallAliases = route.provider.authMode === "forward"
|
|
1543
|
+
? new Map<string, { namespace: string; name: string }>()
|
|
1544
|
+
: imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
|
|
1545
|
+
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
1546
|
+
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
1547
|
+
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
1548
|
+
// way a chained turn keeps its earlier context is the local replay expansion. Record
|
|
1549
|
+
// completed passthrough responses (force bypasses Codex's blanket store:false) so the next
|
|
1550
|
+
// turn's expansion hits. Never record a body whose own previous_response_id failed to
|
|
1551
|
+
// expand: its input is a delta, and storing it would replay a truncated conversation.
|
|
1552
|
+
// Compaction turns are excluded: _rawBody still carries the full pre-compaction history and
|
|
1553
|
+
// recording it would let a later expansion rehydrate the chain Codex just replaced.
|
|
1554
|
+
const passthroughRecordEligible = parsed._compactionRequest !== true
|
|
1555
|
+
&& (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
|
|
1556
|
+
const rememberPassthroughResponse = passthroughRecordEligible
|
|
1557
|
+
? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
|
|
1558
|
+
rememberResponseState(parsed._rawBody, response, undefined, { force: true })
|
|
1559
|
+
: undefined;
|
|
1560
|
+
if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
1561
|
+
console.warn(
|
|
1562
|
+
`[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
|
|
1563
|
+
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
let request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
1567
|
+
recordAdapterReasoning(logCtx, request);
|
|
1568
|
+
const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
1569
|
+
? request.usageLog.inputTokens
|
|
1570
|
+
: undefined;
|
|
1571
|
+
if (passthroughEstimate !== undefined) {
|
|
1572
|
+
logCtx.usageLogInputTokens = passthroughEstimate;
|
|
1573
|
+
}
|
|
1574
|
+
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
1575
|
+
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
1576
|
+
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
1577
|
+
const upstream = new AbortController();
|
|
1578
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
1579
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
1580
|
+
let upstreamResponse: Response;
|
|
1581
|
+
const transportFailureResponse = (err: unknown): Response => {
|
|
1582
|
+
upstream.abort();
|
|
1583
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1584
|
+
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
1585
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1586
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1587
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1588
|
+
modelId: route.modelId,
|
|
1589
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1590
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1591
|
+
writerGeneration: authCtx.writerGeneration,
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
const msg = outcome === "timeout"
|
|
1595
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
1596
|
+
: describeUpstreamConnectFailure(err, connectMs);
|
|
1597
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
1598
|
+
};
|
|
1599
|
+
try {
|
|
1600
|
+
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
1601
|
+
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
1602
|
+
// Body is a replayable string; nothing has streamed to the client yet.
|
|
1603
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
1604
|
+
recovery => {
|
|
1605
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
|
|
1606
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
1607
|
+
method: request.method,
|
|
1608
|
+
headers: request.headers,
|
|
1609
|
+
body: request.body,
|
|
1610
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
1611
|
+
},
|
|
1612
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
1613
|
+
);
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
return transportFailureResponse(err);
|
|
1616
|
+
} finally {
|
|
1617
|
+
request.releaseBodyObservation?.();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1621
|
+
let poolRetryOutcome: number | undefined;
|
|
1622
|
+
if (await shouldRetryCodexPoolAccountModel400(
|
|
1623
|
+
upstreamResponse,
|
|
1624
|
+
route.modelId,
|
|
1625
|
+
options.abortSignal,
|
|
1626
|
+
)) {
|
|
1627
|
+
poolRetryOutcome = 400;
|
|
1628
|
+
} else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
1629
|
+
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
1630
|
+
poolRetryOutcome = upstreamResponse.status;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
if (poolRetryOutcome !== undefined) {
|
|
1634
|
+
const retry = await retryCodexPoolOnAlternateAccount({
|
|
1635
|
+
req,
|
|
1636
|
+
config,
|
|
1637
|
+
route,
|
|
1638
|
+
parsed,
|
|
1639
|
+
logCtx,
|
|
1640
|
+
options,
|
|
1641
|
+
firstAuthCtx: authCtx,
|
|
1642
|
+
firstResponse: upstreamResponse,
|
|
1643
|
+
outcomeStatus: poolRetryOutcome,
|
|
1644
|
+
upstream,
|
|
1645
|
+
connectMs,
|
|
1646
|
+
passthroughEstimate,
|
|
1647
|
+
stream: parsed.stream,
|
|
1648
|
+
});
|
|
1649
|
+
if (retry.kind === "transport") {
|
|
1650
|
+
authCtx = retry.authCtx;
|
|
1651
|
+
return transportFailureResponse(retry.error);
|
|
1652
|
+
}
|
|
1653
|
+
if (retry.kind === "retried") {
|
|
1654
|
+
authCtx = retry.authCtx;
|
|
1655
|
+
request = retry.request;
|
|
1656
|
+
upstreamResponse = retry.upstreamResponse;
|
|
1657
|
+
selectedForwardHeaders = retry.selectedForwardHeaders;
|
|
1658
|
+
// Keep subagent quota-failure health keyed to the account that actually served.
|
|
1659
|
+
subagentFallbackAccountId = retry.authCtx.accountId;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
1664
|
+
const resolvedModel = headers.get("openai-model")?.trim();
|
|
1665
|
+
if (resolvedModel) logCtx.resolvedModel = resolvedModel;
|
|
1666
|
+
if (isUsageDebugEnabled()) {
|
|
1667
|
+
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
1668
|
+
if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
|
|
1669
|
+
}
|
|
1670
|
+
// The chatgpt backend may omit Content-Type on SSE responses. Fall back to
|
|
1671
|
+
// treating a successful body as SSE when the caller requested streaming.
|
|
1672
|
+
const passthroughCt = headers.get("content-type")?.toLowerCase();
|
|
1673
|
+
const isEventStream = passthroughCt?.includes("text/event-stream")
|
|
1674
|
+
|| (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
|
|
1675
|
+
const terminalRecorder = codexForwardTerminalOutcomeRecorder(
|
|
1676
|
+
config,
|
|
1677
|
+
authCtx,
|
|
1678
|
+
route.provider,
|
|
1679
|
+
route.modelId,
|
|
1680
|
+
logCtx,
|
|
1681
|
+
req.headers.get("x-codex-parent-thread-id"),
|
|
1682
|
+
);
|
|
1683
|
+
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
1684
|
+
// Capture quota from upstream response for multi-account tracking
|
|
1685
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1686
|
+
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
1687
|
+
// Prefer primary when present, fall back to secondary for compatibility.
|
|
1688
|
+
const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
|
|
1689
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
1690
|
+
applyAccountQuotaFromUpstreamHeaders(
|
|
1691
|
+
authCtx.accountId,
|
|
1692
|
+
upstreamResponse.headers,
|
|
1693
|
+
authCtx.writerGeneration,
|
|
1694
|
+
);
|
|
1695
|
+
if (terminalBodyWillRecord) {
|
|
1696
|
+
options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
|
|
1697
|
+
terminalRecorder(status, httpStatusOverride);
|
|
1698
|
+
if (status === "failed") {
|
|
1699
|
+
const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402
|
|
1700
|
+
|| logCtx.terminalHttpStatus === 429
|
|
1701
|
+
|| logCtx.terminalHttpStatus === 402
|
|
1702
|
+
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
1703
|
+
: undefined;
|
|
1704
|
+
if (quotaFailureMessage !== undefined) {
|
|
1705
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
1706
|
+
req.headers,
|
|
1707
|
+
subagentQuotaFailureModel,
|
|
1708
|
+
quotaFailureMessage,
|
|
1709
|
+
config,
|
|
1710
|
+
subagentFallbackAccountId,
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
options.onNativePassthroughTerminal?.(status);
|
|
1715
|
+
});
|
|
1716
|
+
} else if (!shouldDeferCodexResetDerivedCooldown(
|
|
1717
|
+
upstreamResponse,
|
|
1718
|
+
options.deferCodexResetDerivedCooldown,
|
|
1719
|
+
)) {
|
|
1720
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
1721
|
+
...quotaMeta,
|
|
1722
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1723
|
+
modelId: route.modelId,
|
|
1724
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1725
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1726
|
+
writerGeneration: authCtx.writerGeneration,
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Non-2xx passthrough failures must never reach Codex as an empty body —
|
|
1732
|
+
// Codex renders that as the opaque "Unknown error" (#452). Combo attempts
|
|
1733
|
+
// keep their typed failure envelope. Non-empty bodies are relayed verbatim
|
|
1734
|
+
// (headers included) so pool-retry Activation B/D and client diagnostics stay intact.
|
|
1735
|
+
if (!upstreamResponse.ok) {
|
|
1736
|
+
if (options.comboAttempt) {
|
|
1737
|
+
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
|
|
1738
|
+
options.onConsumedComboFailure?.(failure);
|
|
1739
|
+
return failure.response;
|
|
1740
|
+
}
|
|
1741
|
+
const errorText = await upstreamResponse.text().catch(() => "");
|
|
1742
|
+
return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
|
|
1743
|
+
statusText: upstreamResponse.statusText,
|
|
1744
|
+
headers,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
|
|
1749
|
+
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
|
|
1750
|
+
// native relay, never enters JS Sink.write); branch[1] is consumed in the
|
|
1751
|
+
// background for terminal-outcome/quota inspection only.
|
|
1752
|
+
// #314 alternative shape: win32 no-rewrite traffic follows the runtime/config
|
|
1753
|
+
// gate; darwin no-rewrite traffic joins it only for explicit
|
|
1754
|
+
// `streamMode: "eager-relay"` opt-in. Darwin `auto` always stays tee. The
|
|
1755
|
+
// eager shape skips tee and uses one bounded reader with inline inspection
|
|
1756
|
+
// (src/server/relay-eager.ts; policy:
|
|
1757
|
+
// devlog/_plan/260731_macos_rss_retention/100_darwin_eager_optin.md).
|
|
1758
|
+
// The bundled known-bad runtime remains on tee by default on both platforms.
|
|
1759
|
+
if (isEventStream && upstreamResponse.body) {
|
|
1760
|
+
const repairConfig = route.provider.responsesItemIdRepair;
|
|
1761
|
+
const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
|
|
1762
|
+
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
|
|
1763
|
+
const payloadRewrites = [
|
|
1764
|
+
createImageGenCallRestoreRewrite(imageGenCallAliases),
|
|
1765
|
+
hasResponsesItemIdRepair(repairConfig)
|
|
1766
|
+
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
|
|
1767
|
+
: undefined,
|
|
1768
|
+
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
1769
|
+
// #864: win32 rewrite traffic must never enter the tee()+JS-pull chain
|
|
1770
|
+
// (Bun#32111 JS-sink segfault — text frames pass, the terminal block is
|
|
1771
|
+
// lost). The eager single reader applies the same rewrites inline.
|
|
1772
|
+
const win32EagerRewrite = isWin32EagerRewrite(process.platform, needsClientRewrite);
|
|
1773
|
+
const eagerPath = selectEagerPath(
|
|
1774
|
+
process.platform,
|
|
1775
|
+
needsClientRewrite,
|
|
1776
|
+
config.streamMode ?? "auto",
|
|
1777
|
+
);
|
|
1778
|
+
if (eagerPath?.useEagerRelay || win32EagerRewrite) {
|
|
1779
|
+
const turnAc = new AbortController();
|
|
1780
|
+
linkAbortSignal(upstream, turnAc.signal);
|
|
1781
|
+
registerTurn(turnAc, options.turnAdmissionLease);
|
|
1782
|
+
const reportNativeTerminal = recordTerminalOutcomes
|
|
1783
|
+
? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
|
|
1784
|
+
terminalRecorder?.(status, httpStatusOverride);
|
|
1785
|
+
if (status === "failed") {
|
|
1786
|
+
const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402
|
|
1787
|
+
|| logCtx.terminalHttpStatus === 429
|
|
1788
|
+
|| logCtx.terminalHttpStatus === 402
|
|
1789
|
+
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
1790
|
+
: undefined;
|
|
1791
|
+
if (quotaFailureMessage !== undefined) {
|
|
1792
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
1793
|
+
req.headers,
|
|
1794
|
+
subagentQuotaFailureModel,
|
|
1795
|
+
quotaFailureMessage,
|
|
1796
|
+
config,
|
|
1797
|
+
subagentFallbackAccountId,
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
options.onNativePassthroughTerminal?.(status);
|
|
1802
|
+
}
|
|
1803
|
+
: undefined;
|
|
1804
|
+
const inspector = createSseInspector({
|
|
1805
|
+
onTerminal: reportNativeTerminal,
|
|
1806
|
+
logCtx,
|
|
1807
|
+
onCompletedResponse: rememberPassthroughResponse,
|
|
1808
|
+
onFirstOutput: options.onFirstOutput,
|
|
1809
|
+
});
|
|
1810
|
+
const eagerBody = relaySseEagerBounded(upstreamResponse.body, turnAc, {
|
|
1811
|
+
inspectChunk: chunk => inspector.feed(chunk),
|
|
1812
|
+
finishInspection: () => inspector.finish(),
|
|
1813
|
+
disposeInspection: () => inspector.dispose(),
|
|
1814
|
+
sawTerminal: () => inspector.reported(),
|
|
1815
|
+
...(win32EagerRewrite
|
|
1816
|
+
? { rewritePayload: composeSsePayloadRewrites(...payloadRewrites) }
|
|
1817
|
+
: {}),
|
|
1818
|
+
onSynthetic: kind => {
|
|
1819
|
+
if (!reportNativeTerminal) return;
|
|
1820
|
+
if (kind === "incomplete") {
|
|
1821
|
+
logCtx.terminalSource = "synthetic";
|
|
1822
|
+
reportNativeTerminal("incomplete");
|
|
1823
|
+
} else {
|
|
1824
|
+
logCtx.transportPhase = "mid_stream";
|
|
1825
|
+
logCtx.terminalSource = "synthetic";
|
|
1826
|
+
reportNativeTerminal("failed", 502);
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
onClientCancel: () => options.onNativePassthroughCancel?.(),
|
|
1830
|
+
onDone: () => unregisterTurn(turnAc),
|
|
1831
|
+
}, win32EagerRewrite ? { rewriteBudget: translatorBudget } : undefined);
|
|
1832
|
+
// selectEagerPath admits only no-rewrite traffic on both eligible platforms;
|
|
1833
|
+
// win32 rewrite traffic reaches this relay too, but with the payload rewrite
|
|
1834
|
+
// applied inline — never via an image/item-id JS pull wrapper (#32111, #864).
|
|
1835
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1836
|
+
return markEagerRelaySseResponse(
|
|
1837
|
+
markNativePassthroughSseResponse(new Response(eagerBody, {
|
|
1838
|
+
status: upstreamResponse.status,
|
|
1839
|
+
headers,
|
|
1840
|
+
})),
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
1844
|
+
const turnAc = new AbortController();
|
|
1845
|
+
const clientGone = new AbortController();
|
|
1846
|
+
linkAbortSignal(upstream, turnAc.signal);
|
|
1847
|
+
registerTurn(turnAc, options.turnAdmissionLease);
|
|
1848
|
+
const inspectionConsumerOptions = {
|
|
1849
|
+
clientGoneSignal: clientGone.signal,
|
|
1850
|
+
drainBounds: { ms: 15_000, bytes: 32 * 1024 * 1024 },
|
|
1851
|
+
upstream,
|
|
1852
|
+
};
|
|
1853
|
+
if (recordTerminalOutcomes) {
|
|
1854
|
+
// A real terminal was parsed from the (teed) inspection stream — record it as the outcome
|
|
1855
|
+
// even if the client has already disconnected: the turn genuinely reached that terminal, so
|
|
1856
|
+
// it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
|
|
1857
|
+
// client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
|
|
1858
|
+
const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
|
|
1859
|
+
terminalRecorder?.(status, httpStatusOverride);
|
|
1860
|
+
if (status === "failed") {
|
|
1861
|
+
const quotaFailureMessage = httpStatusOverride === 429 || httpStatusOverride === 402
|
|
1862
|
+
|| logCtx.terminalHttpStatus === 429
|
|
1863
|
+
|| logCtx.terminalHttpStatus === 402
|
|
1864
|
+
? (httpStatusOverride ?? logCtx.terminalHttpStatus)
|
|
1865
|
+
: undefined;
|
|
1866
|
+
if (quotaFailureMessage !== undefined) {
|
|
1867
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
1868
|
+
req.headers,
|
|
1869
|
+
subagentQuotaFailureModel,
|
|
1870
|
+
quotaFailureMessage,
|
|
1871
|
+
config,
|
|
1872
|
+
subagentFallbackAccountId,
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
options.onNativePassthroughTerminal?.(status);
|
|
1877
|
+
};
|
|
1878
|
+
consumeForInspection(
|
|
1879
|
+
inspectBody,
|
|
1880
|
+
reportNativeTerminal,
|
|
1881
|
+
turnAc.signal,
|
|
1882
|
+
() => unregisterTurn(turnAc),
|
|
1883
|
+
logCtx,
|
|
1884
|
+
() => options.onNativePassthroughCancel?.(),
|
|
1885
|
+
rememberPassthroughResponse,
|
|
1886
|
+
options.onFirstOutput,
|
|
1887
|
+
inspectionConsumerOptions,
|
|
1888
|
+
);
|
|
1889
|
+
} else {
|
|
1890
|
+
consumeForResponseLogMetadata(
|
|
1891
|
+
inspectBody,
|
|
1892
|
+
logCtx,
|
|
1893
|
+
turnAc.signal,
|
|
1894
|
+
() => unregisterTurn(turnAc),
|
|
1895
|
+
rememberPassthroughResponse,
|
|
1896
|
+
options.onFirstOutput,
|
|
1897
|
+
inspectionConsumerOptions,
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1901
|
+
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
|
|
1902
|
+
// relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
|
|
1903
|
+
// mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
|
|
1904
|
+
const rewrittenBody = payloadRewrites.length > 0
|
|
1905
|
+
? relaySseWithPayloadRewrite(nativeBody, composeSsePayloadRewrites(...payloadRewrites), translatorBudget)
|
|
1906
|
+
: nativeBody;
|
|
1907
|
+
const clientBody = process.platform === "win32" && !needsClientRewrite
|
|
1908
|
+
? nativeBody
|
|
1909
|
+
: relaySseWithFailedTail(rewrittenBody, upstream, reason => clientGone.abort(reason));
|
|
1910
|
+
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
1911
|
+
status: upstreamResponse.status,
|
|
1912
|
+
headers,
|
|
1913
|
+
}));
|
|
1914
|
+
}
|
|
1915
|
+
if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
1916
|
+
const text = await upstreamResponse.text();
|
|
1917
|
+
inspectResponseLogJson(logCtx, text);
|
|
1918
|
+
if (rememberPassthroughResponse) {
|
|
1919
|
+
try {
|
|
1920
|
+
rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
|
|
1921
|
+
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
1922
|
+
}
|
|
1923
|
+
return new Response(restoreImageGenCallsInJson(text, imageGenCallAliases), {
|
|
1924
|
+
status: upstreamResponse.status,
|
|
1925
|
+
statusText: upstreamResponse.statusText,
|
|
1926
|
+
headers,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
const body = relayWithAbort(upstreamResponse.body, upstream);
|
|
1930
|
+
const turnAc = new AbortController();
|
|
1931
|
+
const tracked = body ? trackStreamLifetime(body, turnAc, undefined, options.turnAdmissionLease) : null;
|
|
1932
|
+
return new Response(tracked, {
|
|
1933
|
+
status: upstreamResponse.status,
|
|
1934
|
+
headers,
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
|
|
1939
|
+
// Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
|
|
1940
|
+
// leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
|
|
1941
|
+
// completion instead of the synthetic compaction item Codex expects (#424).
|
|
1942
|
+
//
|
|
1943
|
+
// Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
|
|
1944
|
+
// Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
|
|
1945
|
+
// - non-runTurn: web-search wins over image when both eligible (documented priority)
|
|
1946
|
+
// - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
|
|
1947
|
+
// can proceed for web-search-only turns
|
|
1948
|
+
const wsPlan = !routedCompaction
|
|
1949
|
+
? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
|
|
1950
|
+
: undefined;
|
|
1951
|
+
const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
|
|
1952
|
+
const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
|
|
1953
|
+
const canRunWebSearch = !!wsPlan && !adapter.runTurn;
|
|
1954
|
+
if ((imgPlan || vidPlan) && canRunWebSearch) {
|
|
1955
|
+
// Web search takes priority when both are active — the media bridge cannot run
|
|
1956
|
+
// alongside runWithWebSearch. Surface a runtime signal so the user knows their
|
|
1957
|
+
// configured video/image bridge was skipped for this turn, rather than silently
|
|
1958
|
+
// dropping a paid capability.
|
|
1959
|
+
if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
|
|
1960
|
+
if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
|
|
1961
|
+
}
|
|
1962
|
+
if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
|
|
1963
|
+
// The image bridge detects a hosted image_generation tool and requires streaming.
|
|
1964
|
+
// The video bridge activates from config and injects a tool — it also needs streaming
|
|
1965
|
+
// (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
|
|
1966
|
+
// the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
|
|
1967
|
+
if (!parsed.stream) {
|
|
1968
|
+
if (imgPlan) {
|
|
1969
|
+
return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
|
|
1970
|
+
}
|
|
1971
|
+
// Video-only: skip bridge for non-streaming requests
|
|
1972
|
+
} else {
|
|
1973
|
+
// Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
|
|
1974
|
+
const priorTools = parsed.context.tools ?? [];
|
|
1975
|
+
const bridgeTools = [...priorTools.filter(t => {
|
|
1976
|
+
if (t.imageGeneration) return false;
|
|
1977
|
+
if (t.videoGeneration) return false;
|
|
1978
|
+
if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
|
|
1979
|
+
if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
|
|
1980
|
+
// Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
|
|
1981
|
+
if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
|
|
1982
|
+
return true;
|
|
1983
|
+
})];
|
|
1984
|
+
const existingNames = new Set(bridgeTools.map(t => t.name));
|
|
1985
|
+
if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
|
|
1986
|
+
if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
|
|
1987
|
+
parsed.context.tools = bridgeTools;
|
|
1988
|
+
// Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
|
|
1989
|
+
// Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
|
|
1990
|
+
// image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
|
|
1991
|
+
const tc = parsed.options.toolChoice;
|
|
1992
|
+
if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
|
|
1993
|
+
const mapped = tc.allowedTools.map(name =>
|
|
1994
|
+
name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
|
|
1995
|
+
? IMAGE_GEN_TOOL_NAME
|
|
1996
|
+
: name,
|
|
1997
|
+
);
|
|
1998
|
+
parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
|
|
1999
|
+
} else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
|
|
2000
|
+
&& (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
|
|
2001
|
+
parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
|
|
2002
|
+
}
|
|
2003
|
+
const imgResponse = await runWithImageBridge({
|
|
2004
|
+
parsed, adapter,
|
|
2005
|
+
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
2006
|
+
...(imgPlan ? { plan: imgPlan } : {}),
|
|
2007
|
+
...(vidPlan ? { videoPlan: vidPlan } : {}),
|
|
2008
|
+
forwardHeaders: selectedForwardHeaders,
|
|
2009
|
+
onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens),
|
|
2010
|
+
abortSignal: options.abortSignal,
|
|
2011
|
+
maxRounds: imgPlan && vidPlan
|
|
2012
|
+
? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
|
|
2013
|
+
: imgPlan
|
|
2014
|
+
? clampImageMaxRounds(config.images?.maxRounds)
|
|
2015
|
+
: clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
|
|
2016
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2017
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
2018
|
+
fetchImpl: providerFetch(route.provider),
|
|
2019
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
2020
|
+
...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
|
|
2021
|
+
onUsage: usage => {
|
|
2022
|
+
// Cursor may assign _cursorConversationId inside the image loop's first runTurn;
|
|
2023
|
+
// backfill so Logs can filter/total that opening request (parity with the normal
|
|
2024
|
+
// runTurn branch).
|
|
2025
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
2026
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
2027
|
+
}
|
|
2028
|
+
logCtx.usageFromBridge = true;
|
|
2029
|
+
if (usage) {
|
|
2030
|
+
logCtx.usage = usage;
|
|
2031
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2032
|
+
}
|
|
2033
|
+
},
|
|
2034
|
+
on429: retryAfter => {
|
|
2035
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2036
|
+
retryAfter,
|
|
2037
|
+
now: Date.now(),
|
|
2038
|
+
attemptedKey: route.provider.apiKey,
|
|
2039
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
2040
|
+
});
|
|
2041
|
+
if (!rotated) return null;
|
|
2042
|
+
route.provider = rotated;
|
|
2043
|
+
return resolveAdapter(
|
|
2044
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2045
|
+
config.cacheRetention,
|
|
2046
|
+
);
|
|
2047
|
+
},
|
|
2048
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2049
|
+
...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
|
|
2050
|
+
onCompletedResponse: (response, providerState) =>
|
|
2051
|
+
rememberResponseState(
|
|
2052
|
+
parsed._rawBody,
|
|
2053
|
+
response,
|
|
2054
|
+
continuationStateForResponse(providerState),
|
|
2055
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
2056
|
+
),
|
|
2057
|
+
});
|
|
2058
|
+
if (imgResponse.body) {
|
|
2059
|
+
const imgTurnAc = new AbortController();
|
|
2060
|
+
return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, undefined, options.turnAdmissionLease), {
|
|
2061
|
+
status: imgResponse.status,
|
|
2062
|
+
headers: imgResponse.headers,
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
return imgResponse;
|
|
2066
|
+
} // end else (streaming bridge)
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
2070
|
+
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
2071
|
+
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
2072
|
+
// Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
|
|
2073
|
+
// through web-search instead of being swallowed. runTurn adapters never enter this branch.
|
|
2074
|
+
if (canRunWebSearch && wsPlan) {
|
|
2075
|
+
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
2076
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
2077
|
+
const wsResponse = await runWithWebSearch({
|
|
2078
|
+
parsed, adapter,
|
|
2079
|
+
incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
|
|
2080
|
+
backend: wsPlan.backend,
|
|
2081
|
+
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
2082
|
+
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
2083
|
+
hostedTool: wsPlan.hostedTool,
|
|
2084
|
+
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
2085
|
+
settings: wsPlan.settings,
|
|
2086
|
+
maxSearches: wsPlan.maxSearches,
|
|
2087
|
+
forceEmptyResponseId: true,
|
|
2088
|
+
abortSignal: options.abortSignal,
|
|
2089
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2090
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
2091
|
+
onUsage: usage => {
|
|
2092
|
+
logCtx.usageFromBridge = true;
|
|
2093
|
+
if (usage) {
|
|
2094
|
+
logCtx.usage = usage;
|
|
2095
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
2099
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
2100
|
+
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
2101
|
+
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
2102
|
+
on429: retryAfter => {
|
|
2103
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2104
|
+
retryAfter,
|
|
2105
|
+
now: Date.now(),
|
|
2106
|
+
attemptedKey: route.provider.apiKey,
|
|
2107
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
2108
|
+
});
|
|
2109
|
+
if (!rotated) return null;
|
|
2110
|
+
route.provider = rotated;
|
|
2111
|
+
return resolveAdapter(
|
|
2112
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2113
|
+
config.cacheRetention,
|
|
2114
|
+
);
|
|
2115
|
+
},
|
|
2116
|
+
});
|
|
2117
|
+
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
2118
|
+
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
2119
|
+
if (wsResponse.body) {
|
|
2120
|
+
const wsTurnAc = new AbortController();
|
|
2121
|
+
return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, undefined, options.turnAdmissionLease), {
|
|
2122
|
+
status: wsResponse.status,
|
|
2123
|
+
headers: wsResponse.headers,
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
return wsResponse;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
if (adapter.runTurn) {
|
|
2130
|
+
const runTurnAbort = new AbortController();
|
|
2131
|
+
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
2132
|
+
const queue = createAdapterEventQueue({
|
|
2133
|
+
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
2134
|
+
});
|
|
2135
|
+
const runTurn = async (): Promise<void> => {
|
|
2136
|
+
try {
|
|
2137
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
2138
|
+
await adapter.runTurn?.(
|
|
2139
|
+
parsed,
|
|
2140
|
+
{ headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget },
|
|
2141
|
+
queue.push,
|
|
2142
|
+
);
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
queue.push({
|
|
2145
|
+
type: "error",
|
|
2146
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2147
|
+
});
|
|
2148
|
+
} finally {
|
|
2149
|
+
// Cursor assigns a stable conversation id inside runTurn on the first headerless
|
|
2150
|
+
// turn; backfill so Logs can filter/total that opening request (#330 / #522).
|
|
2151
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
2152
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
2153
|
+
}
|
|
2154
|
+
queue.close();
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2159
|
+
if (parsed.stream) {
|
|
2160
|
+
void runTurn();
|
|
2161
|
+
let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
|
|
2162
|
+
if (options.comboAttempt) {
|
|
2163
|
+
const preflight = await preflightAdapterEvents(eventSource);
|
|
2164
|
+
if (preflight.error || preflight.empty) {
|
|
2165
|
+
runTurnAbort.abort();
|
|
2166
|
+
queue.close();
|
|
2167
|
+
const message = preflight.error?.message ?? "Adapter ended before producing a response";
|
|
2168
|
+
return formatErrorResponse(502, "upstream_error", redactSecretString(message));
|
|
2169
|
+
}
|
|
2170
|
+
eventSource = preflight.stream;
|
|
2171
|
+
}
|
|
2172
|
+
const sseStream = bridgeToResponsesSSE(
|
|
2173
|
+
eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
2174
|
+
() => {
|
|
2175
|
+
runTurnAbort.abort();
|
|
2176
|
+
queue.close();
|
|
2177
|
+
}, 2_000,
|
|
2178
|
+
{
|
|
2179
|
+
translatorBudget,
|
|
2180
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
2181
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
2182
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2183
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2184
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
2185
|
+
onUsage: usage => {
|
|
2186
|
+
// Raw adapter usage, pre wire-normalization: the bridged SSE now always carries
|
|
2187
|
+
// zero-default detail objects, so provenance must come from here (cache_detail_missing).
|
|
2188
|
+
logCtx.usageFromBridge = true;
|
|
2189
|
+
if (usage) {
|
|
2190
|
+
logCtx.usage = usage;
|
|
2191
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2192
|
+
}
|
|
2193
|
+
},
|
|
2194
|
+
...(routedCompaction ? {} : {
|
|
2195
|
+
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) =>
|
|
2196
|
+
rememberResponseState(
|
|
2197
|
+
parsed._rawBody,
|
|
2198
|
+
response,
|
|
2199
|
+
continuationStateForResponse(providerState),
|
|
2200
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
2201
|
+
),
|
|
2202
|
+
}),
|
|
2203
|
+
},
|
|
2204
|
+
);
|
|
2205
|
+
const bridgeTurnAc = new AbortController();
|
|
2206
|
+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, undefined, options.turnAdmissionLease);
|
|
2207
|
+
return new Response(trackedSse, {
|
|
2208
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
await runTurn();
|
|
2213
|
+
const events = await queue.collect();
|
|
2214
|
+
if (options.comboAttempt) {
|
|
2215
|
+
const firstMeaningful = events.find(event => event.type !== "heartbeat");
|
|
2216
|
+
if (!firstMeaningful || firstMeaningful.type === "error") {
|
|
2217
|
+
const message = firstMeaningful?.type === "error"
|
|
2218
|
+
? firstMeaningful.message
|
|
2219
|
+
: "Adapter ended before producing a response";
|
|
2220
|
+
return formatErrorResponse(502, "upstream_error", redactSecretString(message));
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
let providerState: OcxProviderContinuationState | undefined;
|
|
2224
|
+
const json = buildResponseJSON(events, parsed.modelId, {
|
|
2225
|
+
translatorBudget,
|
|
2226
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2227
|
+
toolNsMap,
|
|
2228
|
+
freeformToolNames,
|
|
2229
|
+
toolSearchToolNames,
|
|
2230
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
2231
|
+
onProviderState: state => { providerState = state; },
|
|
2232
|
+
onUsage: usage => {
|
|
2233
|
+
logCtx.usageFromBridge = true;
|
|
2234
|
+
if (usage) {
|
|
2235
|
+
logCtx.usage = usage;
|
|
2236
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2237
|
+
}
|
|
2238
|
+
},
|
|
2239
|
+
});
|
|
2240
|
+
if (!routedCompaction) {
|
|
2241
|
+
rememberResponseState(
|
|
2242
|
+
parsed._rawBody,
|
|
2243
|
+
json,
|
|
2244
|
+
continuationStateForResponse(providerState),
|
|
2245
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
const upstream = new AbortController();
|
|
2252
|
+
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
2253
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
2254
|
+
let activeAdapter = adapter;
|
|
2255
|
+
|
|
2256
|
+
const request = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
2257
|
+
recordAdapterReasoning(logCtx, request);
|
|
2258
|
+
const inputTokenEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
2259
|
+
? request.usageLog.inputTokens
|
|
2260
|
+
: undefined;
|
|
2261
|
+
if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate;
|
|
2262
|
+
let upstreamResponse: Response;
|
|
2263
|
+
try {
|
|
2264
|
+
if (activeAdapter.fetchResponse) {
|
|
2265
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
|
|
2266
|
+
upstreamResponse = await activeAdapter.fetchResponse(request, {
|
|
2267
|
+
abortSignal: upstream.signal,
|
|
2268
|
+
timeoutMs: connectMs,
|
|
2269
|
+
stream: parsed.stream,
|
|
2270
|
+
});
|
|
2271
|
+
} else {
|
|
2272
|
+
upstreamResponse = await fetchWithResetRetry(
|
|
2273
|
+
recovery => {
|
|
2274
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
|
|
2275
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
2276
|
+
method: request.method,
|
|
2277
|
+
headers: request.headers,
|
|
2278
|
+
body: request.body,
|
|
2279
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
2280
|
+
},
|
|
2281
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
} catch (err) {
|
|
2285
|
+
cleanupUpstreamAbort();
|
|
2286
|
+
upstream.abort();
|
|
2287
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
2288
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2289
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
2290
|
+
} finally {
|
|
2291
|
+
request.releaseBodyObservation?.();
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
if (!upstreamResponse.ok) {
|
|
2295
|
+
// Recovery loop: multi-key 429 failover + at most ONE anthropic 413 tightened retry
|
|
2296
|
+
// (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
|
|
2297
|
+
// both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
|
|
2298
|
+
// adapter, and imageTierBias — once armed — rides EVERY subsequent rebuild so a
|
|
2299
|
+
// 413→429 rotation cannot silently undo the tightening.
|
|
2300
|
+
let imageTierBias = 0;
|
|
2301
|
+
let imageRetryAttempted = false;
|
|
2302
|
+
let oauth401ReplayAttempted = false;
|
|
2303
|
+
const rebuildAndRefetch = async (
|
|
2304
|
+
recovery: AttemptRecoveryKind,
|
|
2305
|
+
): Promise<Response | { failed: Response }> => {
|
|
2306
|
+
const retryRequest = await activeAdapter.buildRequest(parsed, {
|
|
2307
|
+
headers: selectedForwardHeaders,
|
|
2308
|
+
translatorBudget,
|
|
2309
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
2310
|
+
});
|
|
2311
|
+
recordAdapterReasoning(logCtx, retryRequest);
|
|
2312
|
+
const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number"
|
|
2313
|
+
? retryRequest.usageLog.inputTokens
|
|
2314
|
+
: undefined;
|
|
2315
|
+
if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
|
|
2316
|
+
logCtx.providerAdapter = activeAdapter.name;
|
|
2317
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2318
|
+
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
|
|
2319
|
+
try {
|
|
2320
|
+
try {
|
|
2321
|
+
return activeAdapter.fetchResponse
|
|
2322
|
+
? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
2323
|
+
: await fetchWithHeaderTimeout(retryRequest.url, {
|
|
2324
|
+
method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
|
|
2325
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
2326
|
+
} finally {
|
|
2327
|
+
retryRequest.releaseBodyObservation?.();
|
|
2328
|
+
}
|
|
2329
|
+
} catch (err) {
|
|
2330
|
+
cleanupUpstreamAbort();
|
|
2331
|
+
upstream.abort();
|
|
2332
|
+
if (options.abortSignal?.aborted) {
|
|
2333
|
+
return { failed: clientCancelledResponse() };
|
|
2334
|
+
}
|
|
2335
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2336
|
+
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
recovery: for (;;) {
|
|
2340
|
+
if (
|
|
2341
|
+
upstreamResponse.status === 401
|
|
2342
|
+
&& isOAuth401ReplayProvider
|
|
2343
|
+
&& sentOAuthSnapshot
|
|
2344
|
+
&& !oauth401ReplayAttempted
|
|
2345
|
+
) {
|
|
2346
|
+
oauth401ReplayAttempted = true;
|
|
2347
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2348
|
+
let refreshed: OAuthAccessSnapshot;
|
|
2349
|
+
try {
|
|
2350
|
+
refreshed = await forceRefreshOAuthAccessSnapshot(sentOAuthSnapshot);
|
|
2351
|
+
} catch (err) {
|
|
2352
|
+
cleanupUpstreamAbort();
|
|
2353
|
+
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
2354
|
+
}
|
|
2355
|
+
sentOAuthSnapshot = refreshed;
|
|
2356
|
+
if (route.providerName === "kiro") {
|
|
2357
|
+
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
2358
|
+
}
|
|
2359
|
+
const refreshedProvider = resolveProviderTransport(
|
|
2360
|
+
route.providerName,
|
|
2361
|
+
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
2362
|
+
parsed.options.promptCacheKey,
|
|
2363
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
2364
|
+
);
|
|
2365
|
+
route.provider = refreshedProvider;
|
|
2366
|
+
activeAdapter = resolveAdapter(
|
|
2367
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
|
|
2368
|
+
config.cacheRetention,
|
|
2369
|
+
);
|
|
2370
|
+
const result = await rebuildAndRefetch("oauth-401");
|
|
2371
|
+
if ("failed" in result) return result.failed;
|
|
2372
|
+
upstreamResponse = result;
|
|
2373
|
+
continue recovery;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
2377
|
+
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
2378
|
+
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
2379
|
+
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
2380
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2381
|
+
retryAfter: upstreamResponse.headers.get("retry-after"),
|
|
2382
|
+
now: Date.now(),
|
|
2383
|
+
attemptedKey: route.provider.apiKey,
|
|
2384
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
2385
|
+
});
|
|
2386
|
+
if (!rotated) break;
|
|
2387
|
+
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
2388
|
+
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
2389
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2390
|
+
route.provider = rotated;
|
|
2391
|
+
activeAdapter = resolveAdapter(
|
|
2392
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2393
|
+
config.cacheRetention,
|
|
2394
|
+
);
|
|
2395
|
+
const result = await rebuildAndRefetch("key-429");
|
|
2396
|
+
if ("failed" in result) return result.failed;
|
|
2397
|
+
upstreamResponse = result;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
|
|
2401
|
+
// with another eligible OAuth account (bounded per request). Disabled by default.
|
|
2402
|
+
while (
|
|
2403
|
+
upstreamResponse.status === 429
|
|
2404
|
+
&& anthropicPoolAccountId
|
|
2405
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
2406
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
2407
|
+
) {
|
|
2408
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
2409
|
+
config,
|
|
2410
|
+
anthropicPoolAccountId,
|
|
2411
|
+
upstreamResponse.headers.get("retry-after"),
|
|
2412
|
+
anthropicSessionKey,
|
|
2413
|
+
);
|
|
2414
|
+
if (!nextAccountId) break;
|
|
2415
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2416
|
+
try {
|
|
2417
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
2418
|
+
anthropicPoolAccountId = nextAccountId;
|
|
2419
|
+
anthropicPoolFailovers += 1;
|
|
2420
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
2421
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
2422
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2423
|
+
activeAdapter = resolveAdapter(
|
|
2424
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2425
|
+
config.cacheRetention,
|
|
2426
|
+
);
|
|
2427
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2428
|
+
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
2429
|
+
if ("failed" in result) return result.failed;
|
|
2430
|
+
upstreamResponse = result;
|
|
2431
|
+
} catch {
|
|
2432
|
+
break;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
// Anthropic 413 request_too_large: rebuild once with every image one tier lower
|
|
2436
|
+
// (spiral guard: single attempt). The biased response re-enters the 429 check above.
|
|
2437
|
+
if (shouldAttemptImageTierRetry({
|
|
2438
|
+
status: upstreamResponse.status,
|
|
2439
|
+
adapterName: activeAdapter.name,
|
|
2440
|
+
parsed,
|
|
2441
|
+
alreadyAttempted: imageRetryAttempted,
|
|
2442
|
+
})) {
|
|
2443
|
+
imageRetryAttempted = true;
|
|
2444
|
+
imageTierBias = 1;
|
|
2445
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2446
|
+
const result = await rebuildAndRefetch("image-413");
|
|
2447
|
+
if ("failed" in result) return result.failed;
|
|
2448
|
+
upstreamResponse = result;
|
|
2449
|
+
continue recovery;
|
|
2450
|
+
}
|
|
2451
|
+
break;
|
|
2452
|
+
}
|
|
2453
|
+
if (!upstreamResponse.ok) {
|
|
2454
|
+
if (options.comboAttempt) {
|
|
2455
|
+
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
|
|
2456
|
+
.finally(cleanupUpstreamAbort);
|
|
2457
|
+
options.onConsumedComboFailure?.(failure);
|
|
2458
|
+
return failure.response;
|
|
2459
|
+
}
|
|
2460
|
+
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
2461
|
+
cleanupUpstreamAbort();
|
|
2462
|
+
recordSubagentQuotaFailureForThreadSpawn(
|
|
2463
|
+
req.headers,
|
|
2464
|
+
subagentQuotaFailureModel,
|
|
2465
|
+
upstreamResponse.status === 429 || upstreamResponse.status === 402
|
|
2466
|
+
? upstreamResponse.status
|
|
2467
|
+
: `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
2468
|
+
config,
|
|
2469
|
+
subagentFallbackAccountId,
|
|
2470
|
+
);
|
|
2471
|
+
// Upstreams occasionally echo request details in error bodies — scrub token-shaped
|
|
2472
|
+
// material before it reaches the client-facing error surface.
|
|
2473
|
+
const message = `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`;
|
|
2474
|
+
const retryAfter = resolveClientRetryAfter({
|
|
2475
|
+
status: upstreamResponse.status,
|
|
2476
|
+
message,
|
|
2477
|
+
upstreamRetryAfter: upstreamResponse.headers.get("retry-after"),
|
|
2478
|
+
});
|
|
2479
|
+
return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
|
|
2480
|
+
...(retryAfter !== undefined ? { retryAfter } : {}),
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
2486
|
+
|
|
2487
|
+
// Claude can return a clean end_turn after announcing an edit without emitting any tool call.
|
|
2488
|
+
// Keep the normal request/recovery path above intact, and use this bounded callback only for the
|
|
2489
|
+
// one internal continuation pass. A continuation failure becomes an in-stream adapter error so
|
|
2490
|
+
// the client never sees a second hidden HTTP response or an unbounded retry loop.
|
|
2491
|
+
const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction;
|
|
2492
|
+
const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator<AdapterEvent> {
|
|
2493
|
+
let imageTierBias = 0;
|
|
2494
|
+
let response: Response | undefined;
|
|
2495
|
+
while (true) {
|
|
2496
|
+
try {
|
|
2497
|
+
const continuationRequest = await activeAdapter.buildRequest(nextParsed, {
|
|
2498
|
+
headers: selectedForwardHeaders,
|
|
2499
|
+
translatorBudget,
|
|
2500
|
+
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
2501
|
+
});
|
|
2502
|
+
recordAdapterReasoning(logCtx, continuationRequest);
|
|
2503
|
+
const continuationEstimate = typeof continuationRequest.usageLog?.inputTokens === "number"
|
|
2504
|
+
? continuationRequest.usageLog.inputTokens
|
|
2505
|
+
: undefined;
|
|
2506
|
+
if (continuationEstimate !== undefined) logCtx.usageLogInputTokens = continuationEstimate;
|
|
2507
|
+
try {
|
|
2508
|
+
if (activeAdapter.fetchResponse) {
|
|
2509
|
+
noteAttemptSend(logCtx.activeAttempt, continuationEstimate);
|
|
2510
|
+
response = await activeAdapter.fetchResponse(continuationRequest, {
|
|
2511
|
+
abortSignal: upstream.signal,
|
|
2512
|
+
timeoutMs: connectMs,
|
|
2513
|
+
stream: nextParsed.stream,
|
|
2514
|
+
});
|
|
2515
|
+
} else {
|
|
2516
|
+
response = await fetchWithResetRetry(
|
|
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
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
} finally {
|
|
2536
|
+
continuationRequest.releaseBodyObservation?.();
|
|
2537
|
+
}
|
|
2538
|
+
} catch (error) {
|
|
2539
|
+
if (options.abortSignal?.aborted) {
|
|
2540
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
2541
|
+
} else {
|
|
2542
|
+
yield { type: "error", message: `Provider continuation failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
2543
|
+
}
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
2548
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
2549
|
+
retryAfter: response.headers.get("retry-after"),
|
|
2550
|
+
now: Date.now(),
|
|
2551
|
+
attemptedKey: route.provider.apiKey,
|
|
2552
|
+
promptCacheKey: nextParsed.options.promptCacheKey,
|
|
2553
|
+
});
|
|
2554
|
+
if (rotated) {
|
|
2555
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
2556
|
+
route.provider = rotated;
|
|
2557
|
+
activeAdapter = resolveAdapter(
|
|
2558
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2559
|
+
config.cacheRetention,
|
|
2560
|
+
);
|
|
2561
|
+
continue;
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
if (
|
|
2565
|
+
response.status === 429
|
|
2566
|
+
&& anthropicPoolAccountId
|
|
2567
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
2568
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
2569
|
+
) {
|
|
2570
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
2571
|
+
config,
|
|
2572
|
+
anthropicPoolAccountId,
|
|
2573
|
+
response.headers.get("retry-after"),
|
|
2574
|
+
anthropicSessionKey,
|
|
2575
|
+
);
|
|
2576
|
+
if (nextAccountId) {
|
|
2577
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
2578
|
+
try {
|
|
2579
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
2580
|
+
anthropicPoolAccountId = nextAccountId;
|
|
2581
|
+
anthropicPoolFailovers += 1;
|
|
2582
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
2583
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
2584
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2585
|
+
activeAdapter = resolveAdapter(
|
|
2586
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
2587
|
+
config.cacheRetention,
|
|
2588
|
+
);
|
|
2589
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2590
|
+
continue;
|
|
2591
|
+
} catch {
|
|
2592
|
+
// fall through to emit continuation error below
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
if (shouldAttemptImageTierRetry({
|
|
2597
|
+
status: response.status,
|
|
2598
|
+
adapterName: activeAdapter.name,
|
|
2599
|
+
parsed: nextParsed,
|
|
2600
|
+
alreadyAttempted: imageTierBias > 0,
|
|
2601
|
+
})) {
|
|
2602
|
+
imageTierBias = 1;
|
|
2603
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
break;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
if (!response.ok) {
|
|
2610
|
+
const errorText = await response.text().catch(() => "unknown error");
|
|
2611
|
+
yield {
|
|
2612
|
+
type: "error",
|
|
2613
|
+
status: response.status,
|
|
2614
|
+
message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
2615
|
+
};
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
try {
|
|
2620
|
+
// Protect the continuation body against a client abort landing between fetch resolution and
|
|
2621
|
+
// reader attach, exactly as the initial response is guarded above (#390/366e3053). Without
|
|
2622
|
+
// this, a client cancel during the continuation reopens the Bun fetch-to-reader abort race.
|
|
2623
|
+
const detachContinuationBodyGuard = cancelBodyOnAbort(response.body, upstream.signal);
|
|
2624
|
+
try {
|
|
2625
|
+
if (nextParsed.stream) {
|
|
2626
|
+
yield* activeAdapter.parseStream(response, translatorBudget);
|
|
2627
|
+
} else if (activeAdapter.parseResponse) {
|
|
2628
|
+
yield* await activeAdapter.parseResponse(response, translatorBudget);
|
|
2629
|
+
} else {
|
|
2630
|
+
yield { type: "error", message: "Provider continuation does not support response parsing" };
|
|
2631
|
+
}
|
|
2632
|
+
} finally {
|
|
2633
|
+
detachContinuationBodyGuard();
|
|
2634
|
+
}
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
if (options.abortSignal?.aborted) {
|
|
2637
|
+
yield { type: "error", message: "client closed request during terminal continuation", status: 499 };
|
|
2638
|
+
} else {
|
|
2639
|
+
yield { type: "error", message: `Provider continuation parse failed: ${redactSecretString(error instanceof Error ? error.message : String(error))}` };
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
|
|
2644
|
+
if (parsed.stream) {
|
|
2645
|
+
const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget);
|
|
2646
|
+
const eventStream = terminalGuardEnabled
|
|
2647
|
+
? guardTerminalEventStream({
|
|
2648
|
+
parsed,
|
|
2649
|
+
firstEvents: initialEventStream,
|
|
2650
|
+
adapterName: activeAdapter.name,
|
|
2651
|
+
maxAutoContinuations: 1,
|
|
2652
|
+
continuation: fetchTerminalGuardContinuation,
|
|
2653
|
+
})
|
|
2654
|
+
: initialEventStream;
|
|
2655
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2656
|
+
const sseStream = bridgeToResponsesSSE(
|
|
2657
|
+
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
2658
|
+
() => upstream.abort(), 2_000,
|
|
2659
|
+
{
|
|
2660
|
+
translatorBudget,
|
|
2661
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
2662
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
2663
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2664
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
2665
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
2666
|
+
onUsage: usage => {
|
|
2667
|
+
// Raw adapter usage, pre wire-normalization (see the runTurn branch above).
|
|
2668
|
+
logCtx.usageFromBridge = true;
|
|
2669
|
+
if (usage) {
|
|
2670
|
+
logCtx.usage = usage;
|
|
2671
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2672
|
+
}
|
|
2673
|
+
},
|
|
2674
|
+
// Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
|
|
2675
|
+
// PRE-compaction history, and a later previous_response_id expansion would rehydrate the
|
|
2676
|
+
// giant stale chain Codex just replaced.
|
|
2677
|
+
...(routedCompaction ? {} : {
|
|
2678
|
+
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) =>
|
|
2679
|
+
rememberResponseState(
|
|
2680
|
+
parsed._rawBody,
|
|
2681
|
+
response,
|
|
2682
|
+
continuationStateForResponse(providerState),
|
|
2683
|
+
activeAdapter.name === "kiro" ? { force: true } : undefined,
|
|
2684
|
+
),
|
|
2685
|
+
}),
|
|
2686
|
+
},
|
|
2687
|
+
);
|
|
2688
|
+
const bridgeTurnAc = new AbortController();
|
|
2689
|
+
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort, options.turnAdmissionLease);
|
|
2690
|
+
return new Response(trackedSse, {
|
|
2691
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
if (activeAdapter.parseResponse) {
|
|
2696
|
+
let events: AdapterEvent[];
|
|
2697
|
+
try {
|
|
2698
|
+
const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget);
|
|
2699
|
+
if (terminalGuardEnabled) {
|
|
2700
|
+
events = [];
|
|
2701
|
+
for await (const event of guardTerminalEventStream({
|
|
2702
|
+
parsed,
|
|
2703
|
+
firstEvents: (async function* () { yield* initialEvents; })(),
|
|
2704
|
+
adapterName: activeAdapter.name,
|
|
2705
|
+
maxAutoContinuations: 1,
|
|
2706
|
+
continuation: fetchTerminalGuardContinuation,
|
|
2707
|
+
})) events.push(event);
|
|
2708
|
+
} else {
|
|
2709
|
+
events = initialEvents;
|
|
2710
|
+
}
|
|
2711
|
+
} finally {
|
|
2712
|
+
cleanupUpstreamAbort();
|
|
2713
|
+
}
|
|
2714
|
+
const { toolNsMap, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
|
|
2715
|
+
let providerState: OcxProviderContinuationState | undefined;
|
|
2716
|
+
const json = buildResponseJSON(events, parsed.modelId, {
|
|
2717
|
+
translatorBudget,
|
|
2718
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
2719
|
+
toolNsMap,
|
|
2720
|
+
freeformToolNames,
|
|
2721
|
+
toolSearchToolNames,
|
|
2722
|
+
...(routedCompaction ? { compaction: true } : {}),
|
|
2723
|
+
onProviderState: state => { providerState = state; },
|
|
2724
|
+
onUsage: usage => {
|
|
2725
|
+
logCtx.usageFromBridge = true;
|
|
2726
|
+
if (usage) {
|
|
2727
|
+
logCtx.usage = usage;
|
|
2728
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
2729
|
+
}
|
|
2730
|
+
},
|
|
2731
|
+
});
|
|
2732
|
+
// See the streaming branch: compaction turns skip the continuation cache.
|
|
2733
|
+
if (!routedCompaction) {
|
|
2734
|
+
rememberResponseState(
|
|
2735
|
+
parsed._rawBody,
|
|
2736
|
+
json,
|
|
2737
|
+
continuationStateForResponse(providerState),
|
|
2738
|
+
activeAdapter.name === "kiro" ? { force: true } : undefined,
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
return formatErrorResponse(400, "invalid_request_error", "Non-streaming not supported by this adapter");
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
|
|
2749
|
+
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void {
|
|
2750
|
+
if (!signal) return () => {};
|
|
2751
|
+
if (signal.aborted) {
|
|
2752
|
+
upstream.abort(signal.reason);
|
|
2753
|
+
return () => {};
|
|
2754
|
+
}
|
|
2755
|
+
const onAbort = () => upstream.abort(signal.reason);
|
|
2756
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2757
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
2758
|
+
}
|