jeopi-ai 16.2.13
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/CHANGELOG.md +4347 -0
- package/README.md +1193 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth-broker/client.d.ts +73 -0
- package/dist/types/auth-broker/discover.d.ts +35 -0
- package/dist/types/auth-broker/index.d.ts +7 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +102 -0
- package/dist/types/auth-broker/server.d.ts +43 -0
- package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
- package/dist/types/auth-broker/types.d.ts +107 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +411 -0
- package/dist/types/auth-gateway/http.d.ts +39 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +36 -0
- package/dist/types/auth-gateway/types.d.ts +123 -0
- package/dist/types/auth-retry.d.ts +124 -0
- package/dist/types/auth-storage.d.ts +1026 -0
- package/dist/types/dialect/anthropic.d.ts +15 -0
- package/dist/types/dialect/catalog.d.ts +3 -0
- package/dist/types/dialect/coercion.d.ts +23 -0
- package/dist/types/dialect/deepseek.d.ts +14 -0
- package/dist/types/dialect/demotion.d.ts +23 -0
- package/dist/types/dialect/examples.d.ts +2 -0
- package/dist/types/dialect/factory.d.ts +3 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/dialect/gemini.d.ts +17 -0
- package/dist/types/dialect/gemma.d.ts +15 -0
- package/dist/types/dialect/glm.d.ts +9 -0
- package/dist/types/dialect/harmony.d.ts +8 -0
- package/dist/types/dialect/hermes.d.ts +9 -0
- package/dist/types/dialect/history.d.ts +3 -0
- package/dist/types/dialect/index.d.ts +11 -0
- package/dist/types/dialect/inventory.d.ts +12 -0
- package/dist/types/dialect/kimi.d.ts +14 -0
- package/dist/types/dialect/minimax.d.ts +3 -0
- package/dist/types/dialect/owned-stream.d.ts +4 -0
- package/dist/types/dialect/qwen3.d.ts +9 -0
- package/dist/types/dialect/rendering.d.ts +45 -0
- package/dist/types/dialect/thinking.d.ts +6 -0
- package/dist/types/dialect/types.d.ts +69 -0
- package/dist/types/dialect/xml.d.ts +9 -0
- package/dist/types/error/abort.d.ts +14 -0
- package/dist/types/error/auth-classify.d.ts +16 -0
- package/dist/types/error/auth.d.ts +27 -0
- package/dist/types/error/aws.d.ts +23 -0
- package/dist/types/error/classes.d.ts +102 -0
- package/dist/types/error/finalize.d.ts +39 -0
- package/dist/types/error/flags.d.ts +79 -0
- package/dist/types/error/format.d.ts +20 -0
- package/dist/types/error/gateway.d.ts +20 -0
- package/dist/types/error/index.d.ts +13 -0
- package/dist/types/error/oauth.d.ts +43 -0
- package/dist/types/error/provider.d.ts +42 -0
- package/dist/types/error/rate-limit.d.ts +59 -0
- package/dist/types/error/retryable.d.ts +27 -0
- package/dist/types/error/validation.d.ts +32 -0
- package/dist/types/index.d.ts +49 -0
- package/dist/types/provider-details.d.ts +24 -0
- package/dist/types/providers/__tests__/google-auth.test.d.ts +1 -0
- package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
- package/dist/types/providers/__tests__/openai-codex-error.test.d.ts +1 -0
- package/dist/types/providers/amazon-bedrock.d.ts +39 -0
- package/dist/types/providers/anthropic-client.d.ts +94 -0
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +497 -0
- package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
- package/dist/types/providers/anthropic-wire.d.ts +318 -0
- package/dist/types/providers/anthropic.d.ts +248 -0
- package/dist/types/providers/aws-credentials.d.ts +53 -0
- package/dist/types/providers/aws-eventstream.d.ts +39 -0
- package/dist/types/providers/aws-sigv4.d.ts +55 -0
- package/dist/types/providers/azure-openai-responses.d.ts +16 -0
- package/dist/types/providers/cursor.d.ts +91 -0
- package/dist/types/providers/devin.d.ts +12 -0
- package/dist/types/providers/error-message.d.ts +27 -0
- package/dist/types/providers/github-copilot-headers.d.ts +40 -0
- package/dist/types/providers/gitlab-duo-workflow.d.ts +254 -0
- package/dist/types/providers/gitlab-duo.d.ts +27 -0
- package/dist/types/providers/google-auth.d.ts +32 -0
- package/dist/types/providers/google-gemini-cli.d.ts +118 -0
- package/dist/types/providers/google-interactions.d.ts +65 -0
- package/dist/types/providers/google-shared.d.ts +203 -0
- package/dist/types/providers/google-types.d.ts +155 -0
- package/dist/types/providers/google-vertex.d.ts +7 -0
- package/dist/types/providers/google.d.ts +4 -0
- package/dist/types/providers/grammar.d.ts +1 -0
- package/dist/types/providers/kimi.d.ts +27 -0
- package/dist/types/providers/mock.d.ts +178 -0
- package/dist/types/providers/ollama.d.ts +7 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +695 -0
- package/dist/types/providers/openai-chat-server.d.ts +16 -0
- package/dist/types/providers/openai-chat-wire.d.ts +644 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +54 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +26 -0
- package/dist/types/providers/openai-codex-responses.d.ts +108 -0
- package/dist/types/providers/openai-completions.d.ts +45 -0
- package/dist/types/providers/openai-reasoning-fallback.d.ts +25 -0
- package/dist/types/providers/openai-responses-server-schema.d.ts +349 -0
- package/dist/types/providers/openai-responses-server.d.ts +17 -0
- package/dist/types/providers/openai-responses-wire.d.ts +6065 -0
- package/dist/types/providers/openai-responses.d.ts +126 -0
- package/dist/types/providers/openai-shared.d.ts +506 -0
- package/dist/types/providers/pi-native-client.d.ts +13 -0
- package/dist/types/providers/pi-native-server.d.ts +69 -0
- package/dist/types/providers/register-builtins.d.ts +32 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +11 -0
- package/dist/types/providers/vision-guard.d.ts +20 -0
- package/dist/types/registry/aimlapi.d.ts +4 -0
- package/dist/types/registry/alibaba-coding-plan.d.ts +8 -0
- package/dist/types/registry/amazon-bedrock.d.ts +5 -0
- package/dist/types/registry/anthropic.d.ts +10 -0
- package/dist/types/registry/api-key-login.d.ts +42 -0
- package/dist/types/registry/api-key-validation.d.ts +43 -0
- package/dist/types/registry/azure.d.ts +4 -0
- package/dist/types/registry/cerebras.d.ts +7 -0
- package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
- package/dist/types/registry/coreweave.d.ts +7 -0
- package/dist/types/registry/cursor.d.ts +7 -0
- package/dist/types/registry/deepseek.d.ts +8 -0
- package/dist/types/registry/derived.d.ts +5 -0
- package/dist/types/registry/devin.d.ts +8 -0
- package/dist/types/registry/firepass.d.ts +16 -0
- package/dist/types/registry/fireworks.d.ts +7 -0
- package/dist/types/registry/github-copilot.d.ts +7 -0
- package/dist/types/registry/gitlab-duo-workflow.d.ts +10 -0
- package/dist/types/registry/gitlab-duo.d.ts +9 -0
- package/dist/types/registry/google-antigravity.d.ts +9 -0
- package/dist/types/registry/google-gemini-cli.d.ts +9 -0
- package/dist/types/registry/google-vertex.d.ts +5 -0
- package/dist/types/registry/google.d.ts +4 -0
- package/dist/types/registry/groq.d.ts +4 -0
- package/dist/types/registry/huggingface.d.ts +7 -0
- package/dist/types/registry/index.d.ts +4 -0
- package/dist/types/registry/kagi.d.ts +14 -0
- package/dist/types/registry/kilo.d.ts +7 -0
- package/dist/types/registry/kimi-code.d.ts +7 -0
- package/dist/types/registry/litellm.d.ts +13 -0
- package/dist/types/registry/llama-cpp.d.ts +8 -0
- package/dist/types/registry/lm-studio.d.ts +8 -0
- package/dist/types/registry/minimax-code-cn.d.ts +6 -0
- package/dist/types/registry/minimax-code.d.ts +6 -0
- package/dist/types/registry/minimax.d.ts +4 -0
- package/dist/types/registry/mistral.d.ts +4 -0
- package/dist/types/registry/moonshot.d.ts +7 -0
- package/dist/types/registry/nanogpt.d.ts +7 -0
- package/dist/types/registry/nvidia.d.ts +7 -0
- package/dist/types/registry/oauth/__tests__/xai-oauth.test.d.ts +1 -0
- package/dist/types/registry/oauth/anthropic.d.ts +23 -0
- package/dist/types/registry/oauth/callback-server.d.ts +72 -0
- package/dist/types/registry/oauth/cursor.d.ts +15 -0
- package/dist/types/registry/oauth/devin.d.ts +5 -0
- package/dist/types/registry/oauth/github-copilot.d.ts +30 -0
- package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +6 -0
- package/dist/types/registry/oauth/gitlab-duo.d.ts +3 -0
- package/dist/types/registry/oauth/google-antigravity.d.ts +11 -0
- package/dist/types/registry/oauth/google-gemini-cli.d.ts +10 -0
- package/dist/types/registry/oauth/google-oauth-shared.d.ts +22 -0
- package/dist/types/registry/oauth/index.d.ts +64 -0
- package/dist/types/registry/oauth/kimi.d.ts +21 -0
- package/dist/types/registry/oauth/minimax-code.d.ts +27 -0
- package/dist/types/registry/oauth/openai-codex.d.ts +33 -0
- package/dist/types/registry/oauth/opencode.d.ts +18 -0
- package/dist/types/registry/oauth/perplexity.d.ts +9 -0
- package/dist/types/registry/oauth/pkce.d.ts +8 -0
- package/dist/types/registry/oauth/types.d.ts +56 -0
- package/dist/types/registry/oauth/wafer.d.ts +1 -0
- package/dist/types/registry/oauth/xai-oauth.d.ts +52 -0
- package/dist/types/registry/oauth/xiaomi.d.ts +25 -0
- package/dist/types/registry/ollama-cloud.d.ts +7 -0
- package/dist/types/registry/ollama.d.ts +12 -0
- package/dist/types/registry/openai-codex-device.d.ts +8 -0
- package/dist/types/registry/openai-codex.d.ts +9 -0
- package/dist/types/registry/openai.d.ts +4 -0
- package/dist/types/registry/opencode-go.d.ts +6 -0
- package/dist/types/registry/opencode-zen.d.ts +6 -0
- package/dist/types/registry/openrouter.d.ts +13 -0
- package/dist/types/registry/parallel.d.ts +14 -0
- package/dist/types/registry/perplexity.d.ts +7 -0
- package/dist/types/registry/qianfan.d.ts +7 -0
- package/dist/types/registry/qwen-portal.d.ts +7 -0
- package/dist/types/registry/registry.d.ts +303 -0
- package/dist/types/registry/sakana.d.ts +7 -0
- package/dist/types/registry/synthetic.d.ts +6 -0
- package/dist/types/registry/tavily.d.ts +14 -0
- package/dist/types/registry/together.d.ts +6 -0
- package/dist/types/registry/types.d.ts +51 -0
- package/dist/types/registry/umans.d.ts +7 -0
- package/dist/types/registry/venice.d.ts +13 -0
- package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
- package/dist/types/registry/vllm.d.ts +7 -0
- package/dist/types/registry/wafer-serverless.d.ts +6 -0
- package/dist/types/registry/xai-oauth.d.ts +7 -0
- package/dist/types/registry/xai.d.ts +4 -0
- package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
- package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
- package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
- package/dist/types/registry/xiaomi.d.ts +6 -0
- package/dist/types/registry/zai.d.ts +7 -0
- package/dist/types/registry/zenmux.d.ts +7 -0
- package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
- package/dist/types/stream.d.ts +44 -0
- package/dist/types/types.d.ts +715 -0
- package/dist/types/usage/claude.d.ts +4 -0
- package/dist/types/usage/gemini.d.ts +2 -0
- package/dist/types/usage/github-copilot.d.ts +7 -0
- package/dist/types/usage/google-antigravity.d.ts +15 -0
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/usage/minimax-code.d.ts +2 -0
- package/dist/types/usage/ollama.d.ts +5 -0
- package/dist/types/usage/openai-codex-base-url.d.ts +18 -0
- package/dist/types/usage/openai-codex-reset.d.ts +79 -0
- package/dist/types/usage/openai-codex.d.ts +3 -0
- package/dist/types/usage/opencode-go.d.ts +2 -0
- package/dist/types/usage/shared.d.ts +1 -0
- package/dist/types/usage/zai.d.ts +2 -0
- package/dist/types/usage.d.ts +346 -0
- package/dist/types/utils/abort.d.ts +25 -0
- package/dist/types/utils/anthropic-auth.d.ts +35 -0
- package/dist/types/utils/block-symbols.d.ts +20 -0
- package/dist/types/utils/deterministic-id.d.ts +16 -0
- package/dist/types/utils/empty-completion-retry.d.ts +21 -0
- package/dist/types/utils/event-stream.d.ts +30 -0
- package/dist/types/utils/foundry.d.ts +1 -0
- package/dist/types/utils/google-validation.d.ts +2 -0
- package/dist/types/utils/harmony-leak.d.ts +118 -0
- package/dist/types/utils/http-inspector.d.ts +30 -0
- package/dist/types/utils/idle-iterator.d.ts +137 -0
- package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
- package/dist/types/utils/openai-http.d.ts +54 -0
- package/dist/types/utils/openrouter-headers.d.ts +1 -0
- package/dist/types/utils/parse-bind.d.ts +23 -0
- package/dist/types/utils/provider-response.d.ts +3 -0
- package/dist/types/utils/proxy.d.ts +29 -0
- package/dist/types/utils/request-debug.d.ts +29 -0
- package/dist/types/utils/retry-after.d.ts +4 -0
- package/dist/types/utils/retry.d.ts +14 -0
- package/dist/types/utils/schema/adapt.d.ts +24 -0
- package/dist/types/utils/schema/compatibility.d.ts +30 -0
- package/dist/types/utils/schema/dereference.d.ts +11 -0
- package/dist/types/utils/schema/draft.d.ts +10 -0
- package/dist/types/utils/schema/equality.d.ts +4 -0
- package/dist/types/utils/schema/fields.d.ts +54 -0
- package/dist/types/utils/schema/index.d.ts +15 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +20 -0
- package/dist/types/utils/schema/meta-validator.d.ts +2 -0
- package/dist/types/utils/schema/normalize.d.ts +124 -0
- package/dist/types/utils/schema/spill.d.ts +8 -0
- package/dist/types/utils/schema/stamps.d.ts +17 -0
- package/dist/types/utils/schema/strict-tool-validation.d.ts +16 -0
- package/dist/types/utils/schema/types.d.ts +4 -0
- package/dist/types/utils/schema/typescript.d.ts +18 -0
- package/dist/types/utils/schema/wire.d.ts +92 -0
- package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
- package/dist/types/utils/sdk-stream-timeout.d.ts +33 -0
- package/dist/types/utils/sse-debug.d.ts +5 -0
- package/dist/types/utils/stream-markup-healing.d.ts +87 -0
- package/dist/types/utils/thinking-loop.d.ts +102 -0
- package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
- package/dist/types/utils/tool-choice.d.ts +50 -0
- package/dist/types/utils/validation.d.ts +42 -0
- package/dist/types/utils.d.ts +24 -0
- package/package.json +139 -0
- package/src/api-registry.ts +109 -0
- package/src/auth-broker/client.ts +359 -0
- package/src/auth-broker/discover.ts +222 -0
- package/src/auth-broker/index.ts +7 -0
- package/src/auth-broker/refresher.ts +117 -0
- package/src/auth-broker/remote-store.ts +657 -0
- package/src/auth-broker/server.ts +646 -0
- package/src/auth-broker/snapshot-cache.ts +191 -0
- package/src/auth-broker/types.ts +130 -0
- package/src/auth-broker/wire-schemas.ts +249 -0
- package/src/auth-gateway/http.ts +194 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +802 -0
- package/src/auth-gateway/types.ts +151 -0
- package/src/auth-retry.ts +250 -0
- package/src/auth-storage.ts +5576 -0
- package/src/dialect/anthropic.md +31 -0
- package/src/dialect/anthropic.ts +608 -0
- package/src/dialect/catalog.ts +29 -0
- package/src/dialect/coercion.ts +136 -0
- package/src/dialect/deepseek.md +24 -0
- package/src/dialect/deepseek.ts +609 -0
- package/src/dialect/demotion.ts +36 -0
- package/src/dialect/examples.ts +33 -0
- package/src/dialect/factory.ts +34 -0
- package/src/dialect/fenced-thinking.ts +184 -0
- package/src/dialect/gemini.md +44 -0
- package/src/dialect/gemini.ts +597 -0
- package/src/dialect/gemma.md +33 -0
- package/src/dialect/gemma.ts +387 -0
- package/src/dialect/glm.md +32 -0
- package/src/dialect/glm.ts +456 -0
- package/src/dialect/harmony.md +31 -0
- package/src/dialect/harmony.ts +346 -0
- package/src/dialect/hermes.md +25 -0
- package/src/dialect/hermes.ts +206 -0
- package/src/dialect/history.ts +81 -0
- package/src/dialect/index.ts +15 -0
- package/src/dialect/inventory.ts +73 -0
- package/src/dialect/kimi.md +24 -0
- package/src/dialect/kimi.ts +340 -0
- package/src/dialect/minimax.md +31 -0
- package/src/dialect/minimax.ts +95 -0
- package/src/dialect/owned-stream.ts +470 -0
- package/src/dialect/prompt-template.md +12 -0
- package/src/dialect/qwen3.md +28 -0
- package/src/dialect/qwen3.ts +240 -0
- package/src/dialect/rendering.ts +249 -0
- package/src/dialect/thinking.ts +122 -0
- package/src/dialect/types.ts +57 -0
- package/src/dialect/xml.md +22 -0
- package/src/dialect/xml.ts +90 -0
- package/src/error/abort.ts +18 -0
- package/src/error/auth-classify.ts +30 -0
- package/src/error/auth.ts +48 -0
- package/src/error/aws.ts +31 -0
- package/src/error/classes.ts +186 -0
- package/src/error/finalize.ts +69 -0
- package/src/error/flags.ts +506 -0
- package/src/error/format.ts +45 -0
- package/src/error/gateway.ts +96 -0
- package/src/error/index.ts +13 -0
- package/src/error/oauth.ts +58 -0
- package/src/error/provider.ts +62 -0
- package/src/error/rate-limit.ts +161 -0
- package/src/error/retryable.ts +70 -0
- package/src/error/validation.ts +44 -0
- package/src/index.ts +49 -0
- package/src/provider-details.ts +90 -0
- package/src/providers/__tests__/google-auth.test.ts +144 -0
- package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
- package/src/providers/__tests__/openai-codex-error.test.ts +84 -0
- package/src/providers/amazon-bedrock.ts +1042 -0
- package/src/providers/anthropic-client.ts +295 -0
- package/src/providers/anthropic-messages-server-schema.ts +252 -0
- package/src/providers/anthropic-messages-server.ts +756 -0
- package/src/providers/anthropic-wire.ts +318 -0
- package/src/providers/anthropic.ts +4078 -0
- package/src/providers/aws-credentials.ts +586 -0
- package/src/providers/aws-eventstream.ts +181 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +382 -0
- package/src/providers/cursor/proto/agent.proto +3526 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor.ts +2695 -0
- package/src/providers/devin/proto/buf/validate/validate.proto +468 -0
- package/src/providers/devin/proto/buf.gen.yaml +33 -0
- package/src/providers/devin/proto/buf.yaml +17 -0
- package/src/providers/devin/proto/cel/expr/checked.proto +103 -0
- package/src/providers/devin/proto/cel/expr/eval.proto +38 -0
- package/src/providers/devin/proto/cel/expr/explain.proto +15 -0
- package/src/providers/devin/proto/cel/expr/syntax.proto +113 -0
- package/src/providers/devin/proto/cel/expr/value.proto +41 -0
- package/src/providers/devin/proto/connectext/grpc/status/v1/status.proto +11 -0
- package/src/providers/devin/proto/errorspb/errors.proto +56 -0
- package/src/providers/devin/proto/errorspb/hintdetail.proto +7 -0
- package/src/providers/devin/proto/errorspb/markers.proto +10 -0
- package/src/providers/devin/proto/errorspb/tags.proto +12 -0
- package/src/providers/devin/proto/errorspb/testing.proto +6 -0
- package/src/providers/devin/proto/exa/analytics_pb/analytics.proto +188 -0
- package/src/providers/devin/proto/exa/api_server_pb/api_server.proto +2461 -0
- package/src/providers/devin/proto/exa/auth_pb/auth.proto +19 -0
- package/src/providers/devin/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +79 -0
- package/src/providers/devin/proto/exa/browser_preview_pb/browser_preview.proto +32 -0
- package/src/providers/devin/proto/exa/bug_checker_pb/bug_checker.proto +22 -0
- package/src/providers/devin/proto/exa/cascade_plugins_pb/cascade_plugins.proto +262 -0
- package/src/providers/devin/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
- package/src/providers/devin/proto/exa/chat_pb/chat.proto +449 -0
- package/src/providers/devin/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
- package/src/providers/devin/proto/exa/codeium_common_pb/codeium_common.proto +4157 -0
- package/src/providers/devin/proto/exa/context_module_pb/context_module.proto +175 -0
- package/src/providers/devin/proto/exa/cortex_pb/cortex.proto +3268 -0
- package/src/providers/devin/proto/exa/dev_pb/dev.proto +26 -0
- package/src/providers/devin/proto/exa/diff_action_pb/diff_action.proto +75 -0
- package/src/providers/devin/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +103 -0
- package/src/providers/devin/proto/exa/eval_pb/eval.proto +1315 -0
- package/src/providers/devin/proto/exa/extension_server_pb/extension_server.proto +556 -0
- package/src/providers/devin/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
- package/src/providers/devin/proto/exa/index_pb/index.proto +461 -0
- package/src/providers/devin/proto/exa/knowledge_base_pb/knowledge_base.proto +144 -0
- package/src/providers/devin/proto/exa/language_server_pb/language_server.proto +2385 -0
- package/src/providers/devin/proto/exa/model_management_pb/model_management.proto +186 -0
- package/src/providers/devin/proto/exa/opensearch_clients_pb/opensearch_clients.proto +503 -0
- package/src/providers/devin/proto/exa/product_analytics_pb/product_analytics.proto +37 -0
- package/src/providers/devin/proto/exa/prompt_pb/prompt.proto +92 -0
- package/src/providers/devin/proto/exa/reactive_component_pb/reactive_component.proto +96 -0
- package/src/providers/devin/proto/exa/seat_management_pb/seat_management.proto +2680 -0
- package/src/providers/devin/proto/exa/tokenizer_pb/tokenizer.proto +37 -0
- package/src/providers/devin/proto/exa/trainer_pb/config.proto +647 -0
- package/src/providers/devin/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
- package/src/providers/devin/proto/exa/trust_pb/trust.proto +157 -0
- package/src/providers/devin/proto/exa/user_analytics_pb/user_analytics.proto +519 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/base/api_base.proto +28 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/datastore/datastore_v3.proto +484 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/log/log_service.proto +136 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/remote_api/remote_api.proto +42 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/urlfetch/urlfetch_service.proto +61 -0
- package/src/providers/devin/proto/grpc/binlog/v1/binarylog.proto +84 -0
- package/src/providers/devin/proto/io/prometheus/client/metrics.proto +98 -0
- package/src/providers/devin.ts +577 -0
- package/src/providers/error-message.ts +21 -0
- package/src/providers/github-copilot-headers.ts +141 -0
- package/src/providers/gitlab-duo-workflow-chatml-note.md +1 -0
- package/src/providers/gitlab-duo-workflow.ts +3058 -0
- package/src/providers/gitlab-duo.ts +395 -0
- package/src/providers/google-auth.ts +350 -0
- package/src/providers/google-gemini-cli.ts +1362 -0
- package/src/providers/google-interactions.ts +753 -0
- package/src/providers/google-shared.ts +1103 -0
- package/src/providers/google-types.ts +180 -0
- package/src/providers/google-vertex.ts +183 -0
- package/src/providers/google.ts +87 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +52 -0
- package/src/providers/mock.ts +507 -0
- package/src/providers/ollama.ts +773 -0
- package/src/providers/openai-anthropic-shim.ts +152 -0
- package/src/providers/openai-chat-server-schema.ts +242 -0
- package/src/providers/openai-chat-server.ts +715 -0
- package/src/providers/openai-chat-wire.ts +847 -0
- package/src/providers/openai-codex/request-transformer.ts +295 -0
- package/src/providers/openai-codex/response-handler.ts +102 -0
- package/src/providers/openai-codex-responses.ts +3468 -0
- package/src/providers/openai-completions.ts +2173 -0
- package/src/providers/openai-reasoning-fallback.ts +269 -0
- package/src/providers/openai-responses-reasoning-suppression.md +1 -0
- package/src/providers/openai-responses-server-schema.ts +282 -0
- package/src/providers/openai-responses-server.ts +1280 -0
- package/src/providers/openai-responses-wire.ts +6391 -0
- package/src/providers/openai-responses.ts +1022 -0
- package/src/providers/openai-shared.ts +2648 -0
- package/src/providers/pi-native-client.ts +266 -0
- package/src/providers/pi-native-server.ts +242 -0
- package/src/providers/register-builtins.ts +475 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +787 -0
- package/src/providers/vision-guard.ts +54 -0
- package/src/registry/aimlapi.ts +6 -0
- package/src/registry/alibaba-coding-plan.ts +95 -0
- package/src/registry/amazon-bedrock.ts +22 -0
- package/src/registry/anthropic.ts +26 -0
- package/src/registry/api-key-login.ts +112 -0
- package/src/registry/api-key-validation.ts +161 -0
- package/src/registry/azure.ts +6 -0
- package/src/registry/cerebras.ts +23 -0
- package/src/registry/cloudflare-ai-gateway.ts +45 -0
- package/src/registry/coreweave.ts +40 -0
- package/src/registry/cursor.ts +20 -0
- package/src/registry/deepseek.ts +46 -0
- package/src/registry/derived.ts +9 -0
- package/src/registry/devin.ts +15 -0
- package/src/registry/firepass.ts +32 -0
- package/src/registry/fireworks.ts +28 -0
- package/src/registry/github-copilot.ts +22 -0
- package/src/registry/gitlab-duo-workflow.ts +20 -0
- package/src/registry/gitlab-duo.ts +19 -0
- package/src/registry/google-antigravity.ts +22 -0
- package/src/registry/google-gemini-cli.ts +22 -0
- package/src/registry/google-vertex.ts +38 -0
- package/src/registry/google.ts +6 -0
- package/src/registry/groq.ts +6 -0
- package/src/registry/huggingface.ts +29 -0
- package/src/registry/index.ts +4 -0
- package/src/registry/kagi.ts +46 -0
- package/src/registry/kilo.ts +114 -0
- package/src/registry/kimi-code.ts +17 -0
- package/src/registry/litellm.ts +45 -0
- package/src/registry/llama-cpp.ts +35 -0
- package/src/registry/lm-studio.ts +31 -0
- package/src/registry/minimax-code-cn.ts +12 -0
- package/src/registry/minimax-code.ts +12 -0
- package/src/registry/minimax.ts +6 -0
- package/src/registry/mistral.ts +6 -0
- package/src/registry/moonshot.ts +22 -0
- package/src/registry/nanogpt.ts +22 -0
- package/src/registry/nvidia.ts +61 -0
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +104 -0
- package/src/registry/oauth/anthropic.ts +311 -0
- package/src/registry/oauth/callback-server.ts +315 -0
- package/src/registry/oauth/cursor.ts +171 -0
- package/src/registry/oauth/devin.ts +124 -0
- package/src/registry/oauth/github-copilot.ts +369 -0
- package/src/registry/oauth/gitlab-duo-workflow.ts +146 -0
- package/src/registry/oauth/gitlab-duo.ts +222 -0
- package/src/registry/oauth/google-antigravity.ts +209 -0
- package/src/registry/oauth/google-gemini-cli.ts +273 -0
- package/src/registry/oauth/google-oauth-shared.ts +125 -0
- package/src/registry/oauth/index.ts +269 -0
- package/src/registry/oauth/kimi.ts +289 -0
- package/src/registry/oauth/minimax-code.ts +53 -0
- package/src/registry/oauth/oauth.html +311 -0
- package/src/registry/oauth/openai-codex.ts +364 -0
- package/src/registry/oauth/opencode.ts +50 -0
- package/src/registry/oauth/perplexity.ts +228 -0
- package/src/registry/oauth/pkce.ts +18 -0
- package/src/registry/oauth/types.ts +65 -0
- package/src/registry/oauth/wafer.ts +24 -0
- package/src/registry/oauth/xai-oauth.ts +394 -0
- package/src/registry/oauth/xiaomi.ts +211 -0
- package/src/registry/ollama-cloud.ts +36 -0
- package/src/registry/ollama.ts +43 -0
- package/src/registry/openai-codex-device.ts +18 -0
- package/src/registry/openai-codex.ts +19 -0
- package/src/registry/openai.ts +6 -0
- package/src/registry/opencode-go.ts +12 -0
- package/src/registry/opencode-zen.ts +12 -0
- package/src/registry/openrouter.ts +28 -0
- package/src/registry/parallel.ts +45 -0
- package/src/registry/perplexity.ts +13 -0
- package/src/registry/qianfan.ts +27 -0
- package/src/registry/qwen-portal.ts +50 -0
- package/src/registry/registry.ts +161 -0
- package/src/registry/sakana.ts +22 -0
- package/src/registry/synthetic.ts +21 -0
- package/src/registry/tavily.ts +45 -0
- package/src/registry/together.ts +22 -0
- package/src/registry/types.ts +56 -0
- package/src/registry/umans.ts +23 -0
- package/src/registry/venice.ts +33 -0
- package/src/registry/vercel-ai-gateway.ts +38 -0
- package/src/registry/vllm.ts +34 -0
- package/src/registry/wafer-serverless.ts +12 -0
- package/src/registry/xai-oauth.ts +17 -0
- package/src/registry/xai.ts +6 -0
- package/src/registry/xiaomi-token-plan-ams.ts +12 -0
- package/src/registry/xiaomi-token-plan-cn.ts +12 -0
- package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
- package/src/registry/xiaomi.ts +12 -0
- package/src/registry/zai.ts +27 -0
- package/src/registry/zenmux.ts +22 -0
- package/src/registry/zhipu-coding-plan.ts +27 -0
- package/src/stream.ts +1778 -0
- package/src/types.ts +856 -0
- package/src/usage/claude.ts +485 -0
- package/src/usage/gemini.ts +258 -0
- package/src/usage/github-copilot.ts +424 -0
- package/src/usage/google-antigravity.ts +497 -0
- package/src/usage/kimi.ts +271 -0
- package/src/usage/minimax-code.ts +30 -0
- package/src/usage/ollama.ts +41 -0
- package/src/usage/openai-codex-base-url.ts +35 -0
- package/src/usage/openai-codex-reset.ts +174 -0
- package/src/usage/openai-codex.ts +535 -0
- package/src/usage/opencode-go.ts +89 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/zai.ts +321 -0
- package/src/usage.ts +333 -0
- package/src/utils/abort.ts +67 -0
- package/src/utils/anthropic-auth.ts +93 -0
- package/src/utils/block-symbols.ts +32 -0
- package/src/utils/deterministic-id.ts +20 -0
- package/src/utils/empty-completion-retry.ts +159 -0
- package/src/utils/event-stream.ts +171 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/google-validation.ts +25 -0
- package/src/utils/harmony-leak.ts +456 -0
- package/src/utils/http-inspector.ts +168 -0
- package/src/utils/idle-iterator.ts +473 -0
- package/src/utils/leaked-thinking-stream.ts +294 -0
- package/src/utils/openai-http.ts +122 -0
- package/src/utils/openrouter-headers.ts +12 -0
- package/src/utils/parse-bind.ts +56 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/proxy.ts +240 -0
- package/src/utils/request-debug.ts +351 -0
- package/src/utils/retry-after.ts +110 -0
- package/src/utils/retry.ts +59 -0
- package/src/utils/schema/CONSTRAINTS.md +166 -0
- package/src/utils/schema/adapt.ts +36 -0
- package/src/utils/schema/compatibility.ts +435 -0
- package/src/utils/schema/dereference.ts +98 -0
- package/src/utils/schema/draft.ts +341 -0
- package/src/utils/schema/equality.ts +97 -0
- package/src/utils/schema/fields.ts +207 -0
- package/src/utils/schema/index.ts +15 -0
- package/src/utils/schema/json-schema-validator.ts +595 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +1901 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +109 -0
- package/src/utils/schema/strict-tool-validation.ts +117 -0
- package/src/utils/schema/types.ts +10 -0
- package/src/utils/schema/typescript.ts +198 -0
- package/src/utils/schema/wire.ts +789 -0
- package/src/utils/schema/zod-decontaminate.ts +331 -0
- package/src/utils/sdk-stream-timeout.ts +43 -0
- package/src/utils/sse-debug.ts +18 -0
- package/src/utils/stream-markup-healing.ts +247 -0
- package/src/utils/thinking-loop.ts +552 -0
- package/src/utils/tool-call-loop-guard.ts +107 -0
- package/src/utils/tool-choice.ts +99 -0
- package/src/utils/validation.ts +1507 -0
- package/src/utils.ts +171 -0
|
@@ -0,0 +1,3468 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import { scheduler } from "node:timers/promises";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import { calculateCost } from "jeopi-catalog/models";
|
|
5
|
+
import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "jeopi-catalog/wire/codex";
|
|
6
|
+
import {
|
|
7
|
+
$env,
|
|
8
|
+
$flag,
|
|
9
|
+
asRecord,
|
|
10
|
+
fetchWithRetry,
|
|
11
|
+
logger,
|
|
12
|
+
parseStreamingJson,
|
|
13
|
+
readSseJson,
|
|
14
|
+
structuredCloneJSON,
|
|
15
|
+
} from "jeopi-utils";
|
|
16
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
17
|
+
import * as AIError from "../error";
|
|
18
|
+
import { getEnvApiKey } from "../stream";
|
|
19
|
+
import type {
|
|
20
|
+
Api,
|
|
21
|
+
AssistantMessage,
|
|
22
|
+
Context,
|
|
23
|
+
FetchImpl,
|
|
24
|
+
Model,
|
|
25
|
+
ProviderSessionState,
|
|
26
|
+
RawSseEvent,
|
|
27
|
+
ServiceTier,
|
|
28
|
+
StreamFunction,
|
|
29
|
+
StreamOptions,
|
|
30
|
+
TextContent,
|
|
31
|
+
ThinkingContent,
|
|
32
|
+
Tool,
|
|
33
|
+
ToolCall,
|
|
34
|
+
ToolChoice,
|
|
35
|
+
} from "../types";
|
|
36
|
+
import {
|
|
37
|
+
createOpenAIResponsesHistoryPayload,
|
|
38
|
+
getOpenAIResponsesHistoryItems,
|
|
39
|
+
getOpenAIResponsesHistoryPayload,
|
|
40
|
+
normalizeSystemPrompts,
|
|
41
|
+
} from "../utils";
|
|
42
|
+
import { clearStreamingPartialJson, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
|
|
43
|
+
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
44
|
+
import type { RawHttpRequestDump } from "../utils/http-inspector";
|
|
45
|
+
import {
|
|
46
|
+
armPreResponseTimeout,
|
|
47
|
+
getOpenAIStreamFirstEventTimeoutMs,
|
|
48
|
+
getOpenAIStreamIdleTimeoutMs,
|
|
49
|
+
iterateWithIdleTimeout,
|
|
50
|
+
} from "../utils/idle-iterator";
|
|
51
|
+
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
|
|
52
|
+
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
|
53
|
+
import { notifyRawSseEvent } from "../utils/sse-debug";
|
|
54
|
+
import { compactGrammarDefinition } from "./grammar";
|
|
55
|
+
import {
|
|
56
|
+
type CodexReasoningContext,
|
|
57
|
+
type CodexRequestOptions,
|
|
58
|
+
type InputItem,
|
|
59
|
+
type RequestBody,
|
|
60
|
+
shouldUseCodexResponsesLite,
|
|
61
|
+
transformRequestBody,
|
|
62
|
+
} from "./openai-codex/request-transformer";
|
|
63
|
+
import { CodexApiError } from "./openai-codex/response-handler";
|
|
64
|
+
import type {
|
|
65
|
+
ResponseCustomToolCall,
|
|
66
|
+
ResponseFunctionToolCall,
|
|
67
|
+
ResponseInput,
|
|
68
|
+
ResponseInputContent,
|
|
69
|
+
ResponseOutputMessage,
|
|
70
|
+
ResponseReasoningItem,
|
|
71
|
+
ResponseStatus,
|
|
72
|
+
} from "./openai-responses-wire";
|
|
73
|
+
import {
|
|
74
|
+
accumulateCustomToolCallInputDelta,
|
|
75
|
+
accumulateToolCallArgumentsDelta,
|
|
76
|
+
appendMessageContentPart,
|
|
77
|
+
appendMessageTextDelta,
|
|
78
|
+
appendReasoningSummaryPart,
|
|
79
|
+
appendReasoningSummaryPartDone,
|
|
80
|
+
appendReasoningSummaryTextDelta,
|
|
81
|
+
appendResponsesToolResultMessages,
|
|
82
|
+
applyOpenAIServiceTier,
|
|
83
|
+
buildResponsesDeltaInput,
|
|
84
|
+
convertResponsesAssistantMessage,
|
|
85
|
+
convertResponsesInputContent,
|
|
86
|
+
encodeResponsesToolCallId,
|
|
87
|
+
encodeTextSignatureV1,
|
|
88
|
+
finalizeCustomToolCallInputDone,
|
|
89
|
+
finalizePendingResponsesToolCalls,
|
|
90
|
+
finalizeToolCallArgumentsDone,
|
|
91
|
+
isOpenAIResponsesProgressEvent,
|
|
92
|
+
mapOpenAIResponsesStopReason,
|
|
93
|
+
normalizeOpenAIResponsesPromptCacheKey,
|
|
94
|
+
populateResponsesUsageFromResponse,
|
|
95
|
+
promoteResponsesToolUseStopReason,
|
|
96
|
+
} from "./openai-shared";
|
|
97
|
+
import { transformMessages } from "./transform-messages";
|
|
98
|
+
|
|
99
|
+
export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
100
|
+
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
101
|
+
reasoningSummary?: "auto" | "concise" | "detailed" | null;
|
|
102
|
+
/** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models — older ids reject it, so it is suppressed and `context` omitted. */
|
|
103
|
+
reasoningContext?: CodexReasoningContext;
|
|
104
|
+
textVerbosity?: "low" | "medium" | "high";
|
|
105
|
+
include?: string[];
|
|
106
|
+
codexMode?: boolean;
|
|
107
|
+
toolChoice?: ToolChoice;
|
|
108
|
+
preferWebsockets?: boolean;
|
|
109
|
+
serviceTier?: ServiceTier;
|
|
110
|
+
/**
|
|
111
|
+
* Opt into the Responses Lite transport contract. Sends
|
|
112
|
+
* `x-openai-internal-codex-responses-lite: true` on HTTP requests and on the
|
|
113
|
+
* WebSocket upgrade (the marker is connection-scoped there, so lite and
|
|
114
|
+
* non-lite turns never share a pooled socket), strips image detail from
|
|
115
|
+
* input, and disables parallel tool calling — mirroring codex-rs.
|
|
116
|
+
*/
|
|
117
|
+
responsesLite?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Extra `client_metadata` to include in the request body on both transports.
|
|
120
|
+
* The canonical Codex envelope is `client_metadata["x-codex-turn-metadata"]`
|
|
121
|
+
* (JSON string of thread/turn identifiers); flat keys are also accepted.
|
|
122
|
+
*/
|
|
123
|
+
clientMetadata?: Record<string, string>;
|
|
124
|
+
/**
|
|
125
|
+
* Invoked when the server streams a `response.metadata` event carrying
|
|
126
|
+
* ChatGPT moderation metadata (`metadata.openai_chatgpt_moderation_metadata`)
|
|
127
|
+
* for first-party presentation parity. Diagnostic observer: failures are
|
|
128
|
+
* swallowed and must not alter the stream.
|
|
129
|
+
*/
|
|
130
|
+
onModerationMetadata?: (metadata: unknown) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const CODEX_DEBUG = $flag("PI_CODEX_DEBUG");
|
|
134
|
+
const CODEX_MAX_RETRIES = 5;
|
|
135
|
+
const CODEX_RETRY_DELAY_MS = 500;
|
|
136
|
+
const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
|
|
137
|
+
const CODEX_WEBSOCKET_PING_INTERVAL_MS = Number($env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS || 10_000);
|
|
138
|
+
const CODEX_WEBSOCKET_PONG_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS || 60_000);
|
|
139
|
+
const CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY = Number($env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY || 4096);
|
|
140
|
+
/**
|
|
141
|
+
* Maximum quiet period (no inbound frames AND no observed pong) we'll trust a
|
|
142
|
+
* reused WebSocket for before forcing a fresh handshake. Codex backends and
|
|
143
|
+
* intermediaries occasionally evict idle sockets server-side without sending a
|
|
144
|
+
* FIN, leaving the local `readyState` as OPEN while the next `send()` becomes a
|
|
145
|
+
* write into a half-open buffer. Reusing such a socket parks the next request
|
|
146
|
+
* at `#nextMessage` until the first-event/idle timeout fires (issue #1450). The
|
|
147
|
+
* heartbeat below also catches dead sockets, but only after `pongTimeoutMs`
|
|
148
|
+
* (default 60s) and only while a request is active — this gate closes the door
|
|
149
|
+
* earlier and even when the gap between requests is purely client-side (tool
|
|
150
|
+
* execution, user typing, etc.). Set `PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS=0`
|
|
151
|
+
* to disable.
|
|
152
|
+
*/
|
|
153
|
+
const CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = Number($env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS || 30_000);
|
|
154
|
+
/**
|
|
155
|
+
* Steady-state liveness ceiling for the Codex WebSocket transport. Distinct from
|
|
156
|
+
* the OMP-wide stream watchdog removed in #1392: a WebSocket can stay TCP-open
|
|
157
|
+
* indefinitely without exchanging frames (server crash after upgrade, half-open
|
|
158
|
+
* network path), so we still need a transport-internal cap to detect those
|
|
159
|
+
* states and trigger the WS→SSE fallback. Only applies AFTER the first event
|
|
160
|
+
* has arrived — slow first-token paths wait as long as the caller permits.
|
|
161
|
+
*/
|
|
162
|
+
const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS || 300_000);
|
|
163
|
+
/**
|
|
164
|
+
* Maximum wait for the first WebSocket event before falling back to SSE.
|
|
165
|
+
* Unlike a stream watchdog, this triggers a transport switch (not a request
|
|
166
|
+
* failure) — the outer retry loop catches the timeout error and re-runs on
|
|
167
|
+
* SSE. Generous default so legitimately slow first-token providers still get
|
|
168
|
+
* a chance on the WS transport before falling through.
|
|
169
|
+
*/
|
|
170
|
+
const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS || 60_000);
|
|
171
|
+
const CODEX_WEBSOCKET_RETRY_BUDGET = Number($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET || CODEX_MAX_RETRIES);
|
|
172
|
+
const CODEX_WEBSOCKET_RETRY_DELAY_MS = Number($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS || CODEX_RETRY_DELAY_MS);
|
|
173
|
+
const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
|
|
174
|
+
const CODEX_RETRYABLE_EVENT_CODES = new Set(["model_error", "server_error", "internal_error"]);
|
|
175
|
+
const CODEX_RETRYABLE_EVENT_MESSAGE =
|
|
176
|
+
/processing your request|retry your request|temporar(?:y|ily)|overloaded|service.?unavailable|internal error|server error/i;
|
|
177
|
+
const CODEX_PROVIDER_SESSION_STATE_KEY = "openai-codex-responses";
|
|
178
|
+
const X_CODEX_TURN_STATE_HEADER = "x-codex-turn-state";
|
|
179
|
+
const X_MODELS_ETAG_HEADER = "x-models-etag";
|
|
180
|
+
const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite";
|
|
181
|
+
/** WebSocket frames cannot carry per-request HTTP headers; codex-rs mirrors the lite marker into `client_metadata` under this key. */
|
|
182
|
+
const CODEX_WS_RESPONSES_LITE_CLIENT_METADATA_KEY = "ws_request_header_x_openai_internal_codex_responses_lite";
|
|
183
|
+
/** `response.metadata` payload key carrying ChatGPT moderation metadata. */
|
|
184
|
+
const CODEX_MODERATION_METADATA_KEY = "openai_chatgpt_moderation_metadata";
|
|
185
|
+
/** Connection-level websocket failures that should immediately fall back to SSE without retrying. */
|
|
186
|
+
const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed before open", "connection timeout"];
|
|
187
|
+
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
|
|
188
|
+
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
|
189
|
+
const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]);
|
|
190
|
+
// Provider/model failure mode: Codex can keep a response alive by streaming
|
|
191
|
+
// whitespace-only function-call argument deltas forever. Those frames count as
|
|
192
|
+
// transport activity, so idle timers never fire; cap the run before raw debug
|
|
193
|
+
// buffers and partial JSON grow without semantic progress.
|
|
194
|
+
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT = 256;
|
|
195
|
+
const CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT = 16 * 1024;
|
|
196
|
+
const CODEX_WHITESPACE_LOOP_RETRY_LIMIT = 2;
|
|
197
|
+
const CODEX_WHITESPACE_LOOP_RETRY_DELAY_MS = 250;
|
|
198
|
+
|
|
199
|
+
function isCodexStreamProgressEvent(event: unknown): boolean {
|
|
200
|
+
if (isOpenAIResponsesProgressEvent(event)) return true;
|
|
201
|
+
if (!event || typeof event !== "object") return false;
|
|
202
|
+
const type = (event as { type?: unknown }).type;
|
|
203
|
+
return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function extractCodexFrameResponseId(frame: Record<string, unknown>): string | undefined {
|
|
207
|
+
const response = (frame as { response?: { id?: unknown } }).response;
|
|
208
|
+
const id = response?.id;
|
|
209
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function extractCodexFrameSequenceNumber(frame: Record<string, unknown>): number | undefined {
|
|
213
|
+
const raw = (frame as { sequence_number?: unknown }).sequence_number;
|
|
214
|
+
return typeof raw === "number" && Number.isFinite(raw) ? Math.trunc(raw) : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type CodexWebSocketTimeoutDetails = {
|
|
218
|
+
lastEventAt: number;
|
|
219
|
+
lastEventType?: string;
|
|
220
|
+
lastProgressAt: number;
|
|
221
|
+
lastProgressEventType?: string;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function createCodexWebSocketTimeoutMessage(reason: string, details: CodexWebSocketTimeoutDetails): string {
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
const lastEvent = details.lastEventType
|
|
227
|
+
? `${details.lastEventType} ${Math.max(0, now - details.lastEventAt)}ms ago`
|
|
228
|
+
: "none";
|
|
229
|
+
const lastProgress = details.lastProgressEventType
|
|
230
|
+
? `${details.lastProgressEventType} ${Math.max(0, now - details.lastProgressAt)}ms ago`
|
|
231
|
+
: "none";
|
|
232
|
+
return `${reason} (last event: ${lastEvent}; last progress: ${lastProgress})`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
type CodexTransport = "sse" | "websocket";
|
|
236
|
+
type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
|
|
237
|
+
type CodexOutputBlock =
|
|
238
|
+
| ThinkingContent
|
|
239
|
+
| TextContent
|
|
240
|
+
| (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number });
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Per-session request-shape counters. Despite the name, these cover both
|
|
244
|
+
* transports: once stateful SSE chaining is enabled, SSE requests are counted
|
|
245
|
+
* too (the shared chained-request builder records every request it shapes).
|
|
246
|
+
*/
|
|
247
|
+
export interface OpenAICodexWebSocketDebugStats {
|
|
248
|
+
fullContextRequests: number;
|
|
249
|
+
deltaRequests: number;
|
|
250
|
+
lastInputItems: number;
|
|
251
|
+
lastDeltaInputItems?: number;
|
|
252
|
+
lastPreviousResponseId?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Per-session transport state shared by BOTH transports: websocket turn
|
|
257
|
+
* chaining (`previous_response_id` baseline), turn-state/models-etag headers,
|
|
258
|
+
* websocket connection pooling, and debug stats. The name is historical — SSE-only
|
|
259
|
+
* sessions use it too.
|
|
260
|
+
*/
|
|
261
|
+
type CodexWebSocketSessionState = {
|
|
262
|
+
disableWebsocket: boolean;
|
|
263
|
+
lastRequest?: RequestBody;
|
|
264
|
+
lastResponseId?: string;
|
|
265
|
+
lastResponseItems?: InputItem[];
|
|
266
|
+
canAppend: boolean;
|
|
267
|
+
turnState?: string;
|
|
268
|
+
modelsEtag?: string;
|
|
269
|
+
connection?: CodexWebSocketConnection;
|
|
270
|
+
lastTransport?: CodexTransport;
|
|
271
|
+
fallbackCount: number;
|
|
272
|
+
lastFallbackAt?: number;
|
|
273
|
+
prewarmed: boolean;
|
|
274
|
+
stats: OpenAICodexWebSocketDebugStats;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
interface CodexProviderSessionState extends ProviderSessionState {
|
|
278
|
+
webSocketSessions: Map<string, CodexWebSocketSessionState>;
|
|
279
|
+
webSocketPublicToPrivate: Map<string, string>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface CodexRequestContext {
|
|
283
|
+
apiKey: string;
|
|
284
|
+
accountId: string;
|
|
285
|
+
baseUrl: string;
|
|
286
|
+
url: string;
|
|
287
|
+
requestHeaders: Record<string, string>;
|
|
288
|
+
transportSessionId?: string;
|
|
289
|
+
providerSessionState?: CodexProviderSessionState;
|
|
290
|
+
websocketState?: CodexWebSocketSessionState;
|
|
291
|
+
responsesLite: boolean;
|
|
292
|
+
transformedBody: RequestBody;
|
|
293
|
+
rawRequestDump: RawHttpRequestDump;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface CodexRequestSetup {
|
|
297
|
+
requestSignal: AbortSignal;
|
|
298
|
+
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
|
|
299
|
+
requestAbortController: AbortController;
|
|
300
|
+
firstEventTimeoutMs: number | undefined;
|
|
301
|
+
websocketIdleTimeoutMs: number | undefined;
|
|
302
|
+
websocketFirstEventTimeoutMs: number | undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface CodexOpenItem {
|
|
306
|
+
item: CodexEventItem;
|
|
307
|
+
block: CodexOutputBlock | null;
|
|
308
|
+
/** Index of {@link block} in `output.content`; `-1` when no block was created for this item. */
|
|
309
|
+
contentIndex: number;
|
|
310
|
+
itemId?: string;
|
|
311
|
+
outputIndex?: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
class CodexStreamRuntime {
|
|
315
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
316
|
+
requestBodyForState: RequestBody;
|
|
317
|
+
transport: CodexTransport;
|
|
318
|
+
websocketState?: CodexWebSocketSessionState;
|
|
319
|
+
/**
|
|
320
|
+
* Items open on the wire keyed by `item.id`. `response.output_item.added`
|
|
321
|
+
* registers here; `output_item.done` removes. A keyed event whose `item_id`
|
|
322
|
+
* is not present is dropped rather than appended to a sibling.
|
|
323
|
+
*/
|
|
324
|
+
openItems = new Map<string, CodexOpenItem>();
|
|
325
|
+
/**
|
|
326
|
+
* Items open on the wire keyed by `output_index` for streams whose function
|
|
327
|
+
* call items omit `id`; these still carry `output_index` on deltas/done.
|
|
328
|
+
*/
|
|
329
|
+
openItemsByOutputIndex = new Map<number, CodexOpenItem>();
|
|
330
|
+
/**
|
|
331
|
+
* Most recently added open item for events that omit both `item_id` and
|
|
332
|
+
* `output_index`. Always tracks the latest `output_item.added`, including
|
|
333
|
+
* fully keyless items that never make it into the keyed maps; cleared when
|
|
334
|
+
* its item closes.
|
|
335
|
+
*/
|
|
336
|
+
currentEntry: CodexOpenItem | null = null;
|
|
337
|
+
/** Convenience mirrors of {@link currentEntry} for legacy singleton handlers. */
|
|
338
|
+
currentItem: CodexEventItem | null = null;
|
|
339
|
+
currentBlock: CodexOutputBlock | null = null;
|
|
340
|
+
nativeOutputItems: Array<Record<string, unknown>> = [];
|
|
341
|
+
websocketStreamRetries = 0;
|
|
342
|
+
providerRetryAttempt = 0;
|
|
343
|
+
sawTerminalEvent = false;
|
|
344
|
+
canSafelyReplayWebsocketOverSse = true;
|
|
345
|
+
whitespaceToolCallArgumentsDelta?: CodexWhitespaceToolCallArgumentsDeltaState;
|
|
346
|
+
whitespaceLoopRetries = 0;
|
|
347
|
+
|
|
348
|
+
constructor(initial: {
|
|
349
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
350
|
+
requestBodyForState: RequestBody;
|
|
351
|
+
transport: CodexTransport;
|
|
352
|
+
websocketState?: CodexWebSocketSessionState;
|
|
353
|
+
}) {
|
|
354
|
+
this.eventStream = initial.eventStream;
|
|
355
|
+
this.requestBodyForState = initial.requestBodyForState;
|
|
356
|
+
this.transport = initial.transport;
|
|
357
|
+
this.websocketState = initial.websocketState;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Wipe per-attempt accumulator state before a recovery path replays the turn.
|
|
362
|
+
* Keeps {@link openItems} and the legacy singleton-current pointers in lockstep
|
|
363
|
+
* with {@link nativeOutputItems} so a stale delta from the failed attempt can't
|
|
364
|
+
* bind to a sibling on the retry.
|
|
365
|
+
*/
|
|
366
|
+
resetAccumulators(): void {
|
|
367
|
+
this.openItems.clear();
|
|
368
|
+
this.openItemsByOutputIndex.clear();
|
|
369
|
+
this.currentEntry = null;
|
|
370
|
+
this.currentItem = null;
|
|
371
|
+
this.currentBlock = null;
|
|
372
|
+
this.nativeOutputItems.length = 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Look up the open item a Codex stream event targets. `item_id` wins because it
|
|
377
|
+
* uniquely identifies a response item; `output_index` covers idless function
|
|
378
|
+
* call items. A keyed event whose target is already closed is dropped instead
|
|
379
|
+
* of being routed to a sibling. Only streams that omit both keys fall back to
|
|
380
|
+
* {@link currentEntry} — the most recently added item, including fully keyless
|
|
381
|
+
* ones that never reached the keyed maps.
|
|
382
|
+
*/
|
|
383
|
+
openItemForEvent(rawEvent: Record<string, unknown>): CodexOpenItem | null {
|
|
384
|
+
const itemId = typeof rawEvent.item_id === "string" ? rawEvent.item_id : "";
|
|
385
|
+
if (itemId) return this.openItems.get(itemId) ?? null;
|
|
386
|
+
const outputIndex =
|
|
387
|
+
typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
|
|
388
|
+
? Math.trunc(rawEvent.output_index)
|
|
389
|
+
: undefined;
|
|
390
|
+
if (outputIndex !== undefined) return this.openItemsByOutputIndex.get(outputIndex) ?? null;
|
|
391
|
+
return this.currentEntry;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
closeOpenItem(entry: CodexOpenItem | null | undefined): void {
|
|
395
|
+
if (!entry) return;
|
|
396
|
+
if (entry.itemId) this.openItems.delete(entry.itemId);
|
|
397
|
+
if (entry.outputIndex !== undefined) this.openItemsByOutputIndex.delete(entry.outputIndex);
|
|
398
|
+
if (this.currentEntry === entry) {
|
|
399
|
+
this.currentEntry = null;
|
|
400
|
+
this.currentItem = null;
|
|
401
|
+
this.currentBlock = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
observeWhitespaceToolCallArgumentsDelta(
|
|
406
|
+
rawEvent: Record<string, unknown>,
|
|
407
|
+
delta: string,
|
|
408
|
+
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
|
|
409
|
+
if (!isJsonWhitespaceOnly(delta)) {
|
|
410
|
+
this.whitespaceToolCallArgumentsDelta = undefined;
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const itemId =
|
|
415
|
+
typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0
|
|
416
|
+
? rawEvent.item_id
|
|
417
|
+
: (this.currentItem?.id ?? "");
|
|
418
|
+
const outputIndex =
|
|
419
|
+
typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
|
|
420
|
+
? Math.trunc(rawEvent.output_index)
|
|
421
|
+
: undefined;
|
|
422
|
+
const sequenceNumber =
|
|
423
|
+
typeof rawEvent.sequence_number === "number" && Number.isFinite(rawEvent.sequence_number)
|
|
424
|
+
? Math.trunc(rawEvent.sequence_number)
|
|
425
|
+
: undefined;
|
|
426
|
+
let state = this.whitespaceToolCallArgumentsDelta;
|
|
427
|
+
if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) {
|
|
428
|
+
state = {
|
|
429
|
+
itemId,
|
|
430
|
+
outputIndex,
|
|
431
|
+
consecutiveEvents: 0,
|
|
432
|
+
consecutiveChars: 0,
|
|
433
|
+
firstSequenceNumber: sequenceNumber,
|
|
434
|
+
};
|
|
435
|
+
this.whitespaceToolCallArgumentsDelta = state;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
state.consecutiveEvents += 1;
|
|
439
|
+
state.consecutiveChars += delta.length;
|
|
440
|
+
state.lastSequenceNumber = sequenceNumber;
|
|
441
|
+
if (
|
|
442
|
+
state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT &&
|
|
443
|
+
state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT
|
|
444
|
+
) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const itemLabel = itemId ? ` for item ${itemId}` : "";
|
|
449
|
+
const sequenceLabel =
|
|
450
|
+
state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined
|
|
451
|
+
? ""
|
|
452
|
+
: `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`;
|
|
453
|
+
return {
|
|
454
|
+
message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
handleToolCallArgumentsDelta(
|
|
459
|
+
rawEvent: Record<string, unknown>,
|
|
460
|
+
stream: AssistantMessageEventStream,
|
|
461
|
+
output: AssistantMessage,
|
|
462
|
+
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
|
|
463
|
+
const delta = (rawEvent as { delta?: string }).delta || "";
|
|
464
|
+
// Observe BEFORE the item/block guard: degenerate whitespace frames can keep
|
|
465
|
+
// arriving after the item closed (entry detached) and still count as
|
|
466
|
+
// progress for the idle watchdogs — dropping them unobserved would reopen
|
|
467
|
+
// the infinite-loop hole the breaker exists for.
|
|
468
|
+
const interruption = this.observeWhitespaceToolCallArgumentsDelta(rawEvent, delta);
|
|
469
|
+
if (interruption) return interruption;
|
|
470
|
+
// Route to the entry the event keys to; a delta whose item already closed
|
|
471
|
+
// is dropped instead of leaking into a sibling tool call (#2619).
|
|
472
|
+
const entry = this.openItemForEvent(rawEvent);
|
|
473
|
+
if (!entry) return undefined;
|
|
474
|
+
if (entry.item.type !== "function_call" || entry.block?.type !== "toolCall") return undefined;
|
|
475
|
+
accumulateToolCallArgumentsDelta(entry.block, delta, stream, output, entry.contentIndex);
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
handleToolCallArgumentsDone(rawEvent: Record<string, unknown>): void {
|
|
480
|
+
const entry = this.openItemForEvent(rawEvent);
|
|
481
|
+
if (entry?.item.type !== "function_call" || entry.block?.type !== "toolCall") return;
|
|
482
|
+
const args = (rawEvent as { arguments?: string }).arguments;
|
|
483
|
+
if (typeof args === "string") finalizeToolCallArgumentsDone(entry.block, args);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
handleCustomToolCallInputDelta(
|
|
487
|
+
rawEvent: Record<string, unknown>,
|
|
488
|
+
stream: AssistantMessageEventStream,
|
|
489
|
+
output: AssistantMessage,
|
|
490
|
+
): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
|
|
491
|
+
const delta = (rawEvent as { delta?: string }).delta || "";
|
|
492
|
+
// Observe BEFORE the item/block guard — see handleToolCallArgumentsDelta.
|
|
493
|
+
const interruption = this.observeWhitespaceToolCallArgumentsDelta(rawEvent, delta);
|
|
494
|
+
if (interruption) return interruption;
|
|
495
|
+
const entry = this.openItemForEvent(rawEvent);
|
|
496
|
+
if (!entry) return undefined;
|
|
497
|
+
if (entry.item.type !== "custom_tool_call" || entry.block?.type !== "toolCall") return undefined;
|
|
498
|
+
accumulateCustomToolCallInputDelta(entry.block, delta, stream, output, entry.contentIndex);
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
handleCustomToolCallInputDone(rawEvent: Record<string, unknown>): void {
|
|
503
|
+
const entry = this.openItemForEvent(rawEvent);
|
|
504
|
+
if (entry?.item.type !== "custom_tool_call" || entry.block?.type !== "toolCall") return;
|
|
505
|
+
const input = (rawEvent as { input?: string }).input;
|
|
506
|
+
if (typeof input === "string") finalizeCustomToolCallInputDone(entry.block, input);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
handleResponseCreated(rawEvent: Record<string, unknown>): void {
|
|
510
|
+
const response = (rawEvent as { response?: { id?: string } }).response;
|
|
511
|
+
const state = this.websocketState;
|
|
512
|
+
if (state && this.transport === "websocket" && typeof response?.id === "string" && response.id.length > 0) {
|
|
513
|
+
state.lastResponseId = response.id;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
interface CodexWhitespaceToolCallArgumentsDeltaState {
|
|
519
|
+
itemId: string;
|
|
520
|
+
outputIndex?: number;
|
|
521
|
+
consecutiveEvents: number;
|
|
522
|
+
consecutiveChars: number;
|
|
523
|
+
firstSequenceNumber?: number;
|
|
524
|
+
lastSequenceNumber?: number;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
interface CodexWhitespaceToolCallArgumentsDeltaInterruption {
|
|
528
|
+
message: string;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
interface CodexStreamFailureContext {
|
|
532
|
+
model: Model<"openai-codex-responses">;
|
|
533
|
+
output: AssistantMessage;
|
|
534
|
+
options: OpenAICodexResponsesOptions | undefined;
|
|
535
|
+
requestContext: CodexRequestContext;
|
|
536
|
+
startTime: number;
|
|
537
|
+
firstTokenTime?: number;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
interface CodexStreamCompletion {
|
|
541
|
+
firstTokenTime?: number;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function createCodexProviderSessionState(): CodexProviderSessionState {
|
|
545
|
+
const state: CodexProviderSessionState = {
|
|
546
|
+
webSocketSessions: new Map(),
|
|
547
|
+
webSocketPublicToPrivate: new Map(),
|
|
548
|
+
close: () => {
|
|
549
|
+
for (const session of state.webSocketSessions.values()) {
|
|
550
|
+
session.connection?.close("session_disposed");
|
|
551
|
+
}
|
|
552
|
+
state.webSocketSessions.clear();
|
|
553
|
+
state.webSocketPublicToPrivate.clear();
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
return state;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function getCodexProviderSessionState(
|
|
560
|
+
providerSessionState: Map<string, ProviderSessionState> | undefined,
|
|
561
|
+
): CodexProviderSessionState | undefined {
|
|
562
|
+
if (!providerSessionState) return undefined;
|
|
563
|
+
const existing = providerSessionState.get(CODEX_PROVIDER_SESSION_STATE_KEY) as CodexProviderSessionState | undefined;
|
|
564
|
+
if (existing) return existing;
|
|
565
|
+
const created = createCodexProviderSessionState();
|
|
566
|
+
providerSessionState.set(CODEX_PROVIDER_SESSION_STATE_KEY, created);
|
|
567
|
+
return created;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isCodexWebSocketRetryableStreamError(error: unknown): boolean {
|
|
571
|
+
if (!(error instanceof CodexWebSocketTransportError)) return false;
|
|
572
|
+
const message = error.message.toLowerCase();
|
|
573
|
+
return (
|
|
574
|
+
message.includes("websocket closed (") ||
|
|
575
|
+
message.includes("websocket closed before response completion") ||
|
|
576
|
+
message.includes("websocket connection is unavailable") ||
|
|
577
|
+
message.includes("websocket send failed") ||
|
|
578
|
+
message.includes("websocket ping failed") ||
|
|
579
|
+
message.includes("websocket pong timeout") ||
|
|
580
|
+
message.includes("websocket message queue exceeded") ||
|
|
581
|
+
message.includes("websocket request already in progress") ||
|
|
582
|
+
message.includes("idle timeout waiting for websocket") ||
|
|
583
|
+
message.includes("timeout waiting for first websocket event") ||
|
|
584
|
+
message.includes("syntaxerror") ||
|
|
585
|
+
message.includes("json")
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
function toCodexHeaderRecord(value: unknown): Record<string, string> | null {
|
|
589
|
+
if (!value || typeof value !== "object") return null;
|
|
590
|
+
const headers: Record<string, string> = {};
|
|
591
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
592
|
+
if (typeof entry === "string") {
|
|
593
|
+
headers[key] = entry;
|
|
594
|
+
} else if (Array.isArray(entry) && entry.every(item => typeof item === "string")) {
|
|
595
|
+
headers[key] = entry.join(",");
|
|
596
|
+
} else if (typeof entry === "number" || typeof entry === "boolean") {
|
|
597
|
+
headers[key] = String(entry);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return Object.keys(headers).length > 0 ? headers : null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function toCodexHeaders(value: unknown): Headers | undefined {
|
|
604
|
+
if (!value) return undefined;
|
|
605
|
+
if (value instanceof Headers) return value;
|
|
606
|
+
if (Array.isArray(value)) {
|
|
607
|
+
try {
|
|
608
|
+
return new Headers(value as Array<[string, string]>);
|
|
609
|
+
} catch {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const record = toCodexHeaderRecord(value);
|
|
614
|
+
if (!record) return undefined;
|
|
615
|
+
return new Headers(record);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function updateCodexSessionMetadataFromHeaders(
|
|
619
|
+
state: CodexWebSocketSessionState | undefined,
|
|
620
|
+
headers: Headers | Record<string, string> | null | undefined,
|
|
621
|
+
): void {
|
|
622
|
+
if (!state || !headers) return;
|
|
623
|
+
const resolvedHeaders = headers instanceof Headers ? headers : new Headers(headers);
|
|
624
|
+
const turnState = resolvedHeaders.get(X_CODEX_TURN_STATE_HEADER);
|
|
625
|
+
if (turnState && turnState.length > 0) {
|
|
626
|
+
state.turnState = turnState;
|
|
627
|
+
}
|
|
628
|
+
const modelsEtag = resolvedHeaders.get(X_MODELS_ETAG_HEADER);
|
|
629
|
+
if (modelsEtag && modelsEtag.length > 0) {
|
|
630
|
+
state.modelsEtag = modelsEtag;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function extractCodexWebSocketHandshakeHeaders(socket: Bun.WebSocket, openEvent?: Event): Headers | undefined {
|
|
635
|
+
const eventRecord = openEvent as Record<string, unknown> | undefined;
|
|
636
|
+
const eventResponse = eventRecord?.response as Record<string, unknown> | undefined;
|
|
637
|
+
const socketRecord = socket as unknown as Record<string, unknown>;
|
|
638
|
+
const socketResponse = socketRecord.response as Record<string, unknown> | undefined;
|
|
639
|
+
const socketHandshake = socketRecord.handshake as Record<string, unknown> | undefined;
|
|
640
|
+
return (
|
|
641
|
+
toCodexHeaders(eventRecord?.responseHeaders) ??
|
|
642
|
+
toCodexHeaders(eventRecord?.headers) ??
|
|
643
|
+
toCodexHeaders(eventResponse?.headers) ??
|
|
644
|
+
toCodexHeaders(socketRecord.responseHeaders) ??
|
|
645
|
+
toCodexHeaders(socketRecord.handshakeHeaders) ??
|
|
646
|
+
toCodexHeaders(socketResponse?.headers) ??
|
|
647
|
+
toCodexHeaders(socketHandshake?.headers)
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Synthesizes a `RawSseEvent` for a Codex WebSocket frame so the same debug
|
|
652
|
+
// pipeline used for HTTP SSE (`onSseEvent` → `RawSseDebugBuffer.recordEvent`)
|
|
653
|
+
// also captures WebSocket traffic. The `raw` array mirrors SSE wire format
|
|
654
|
+
// (one line per field) so the existing TUI viewer renders it identically:
|
|
655
|
+
// : ws ← <type>
|
|
656
|
+
// event: <type>
|
|
657
|
+
// data: <json>
|
|
658
|
+
// Outbound (client → server) uses `: ws → <type>`. The viewer pretty-prints
|
|
659
|
+
// `data:` JSON lines, so we keep the wire JSON single-line here and let the
|
|
660
|
+
// renderer expand it.
|
|
661
|
+
function notifyCodexWebSocketInbound(
|
|
662
|
+
observer: ((event: RawSseEvent) => void) | undefined,
|
|
663
|
+
parsed: Record<string, unknown>,
|
|
664
|
+
text: string,
|
|
665
|
+
): void {
|
|
666
|
+
const type = typeof parsed.type === "string" ? parsed.type : null;
|
|
667
|
+
const raw: string[] = [`: ws ← ${type ?? "(untyped)"}`];
|
|
668
|
+
if (type) raw.push(`event: ${type}`);
|
|
669
|
+
raw.push(`data: ${text}`);
|
|
670
|
+
notifyRawSseEvent(observer, { event: type, data: text, raw });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function notifyCodexWebSocketOutbound(
|
|
674
|
+
observer: ((event: RawSseEvent) => void) | undefined,
|
|
675
|
+
request: Record<string, unknown>,
|
|
676
|
+
payload: string,
|
|
677
|
+
): void {
|
|
678
|
+
const type = typeof request.type === "string" ? request.type : null;
|
|
679
|
+
const raw: string[] = [`: ws → ${type ?? "(untyped)"}`];
|
|
680
|
+
if (type) raw.push(`event: ${type}`);
|
|
681
|
+
raw.push(`data: ${payload}`);
|
|
682
|
+
notifyRawSseEvent(observer, { event: type, data: payload, raw });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function notifyCodexWebSocketMalformed(
|
|
686
|
+
observer: ((event: RawSseEvent) => void) | undefined,
|
|
687
|
+
data: unknown,
|
|
688
|
+
error: unknown,
|
|
689
|
+
): void {
|
|
690
|
+
const text = typeof data === "string" ? data : "";
|
|
691
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
692
|
+
const raw: string[] = [`: ws ← (parse-error: ${reason})`];
|
|
693
|
+
if (text) raw.push(`data: ${text}`);
|
|
694
|
+
notifyRawSseEvent(observer, { event: "parse_error", data: text, raw });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @internal Exported for tests. */
|
|
698
|
+
export function normalizeCodexToolChoice(
|
|
699
|
+
choice: ToolChoice | undefined,
|
|
700
|
+
tools: Tool[] = [],
|
|
701
|
+
model?: Model<"openai-codex-responses">,
|
|
702
|
+
): string | Record<string, unknown> | undefined {
|
|
703
|
+
if (!choice) return undefined;
|
|
704
|
+
if (typeof choice === "string") return choice;
|
|
705
|
+
const allowFreeform = model ? model.applyPatchToolType === "freeform" : false;
|
|
706
|
+
const mapName = (name: string): Record<string, string> | undefined => {
|
|
707
|
+
const directTool = tools.find(tool => tool.name === name);
|
|
708
|
+
const customTool = allowFreeform
|
|
709
|
+
? tools.find(tool => tool.customFormat && (tool.name === name || tool.customWireName === name))
|
|
710
|
+
: undefined;
|
|
711
|
+
const offeredTool = customTool ?? directTool;
|
|
712
|
+
if (!offeredTool) return undefined;
|
|
713
|
+
return customTool
|
|
714
|
+
? { type: "custom", name: customTool.customWireName ?? customTool.name }
|
|
715
|
+
: { type: "function", name: offeredTool.name };
|
|
716
|
+
};
|
|
717
|
+
if (choice.type === "function") {
|
|
718
|
+
if ("function" in choice && choice.function?.name) {
|
|
719
|
+
return mapName(choice.function.name);
|
|
720
|
+
}
|
|
721
|
+
if ("name" in choice && choice.name) {
|
|
722
|
+
return mapName(choice.name);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (choice.type === "tool" && choice.name) {
|
|
726
|
+
return mapName(choice.name);
|
|
727
|
+
}
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function getCodexServiceTierCostMultiplier(
|
|
732
|
+
model: Pick<Model<"openai-codex-responses">, "id">,
|
|
733
|
+
serviceTier: ServiceTier | "default" | undefined,
|
|
734
|
+
): number {
|
|
735
|
+
switch (serviceTier) {
|
|
736
|
+
case "flex":
|
|
737
|
+
return 0.5;
|
|
738
|
+
case "priority":
|
|
739
|
+
return model.id === "gpt-5.5" ? 2.5 : 2;
|
|
740
|
+
default:
|
|
741
|
+
return 1;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function resolveCodexCostServiceTier(res: unknown, req?: unknown): ServiceTier | "default" | undefined {
|
|
746
|
+
switch (res) {
|
|
747
|
+
case "flex":
|
|
748
|
+
return "flex";
|
|
749
|
+
case "priority":
|
|
750
|
+
return "priority";
|
|
751
|
+
default:
|
|
752
|
+
if (req === "flex" || req === "priority") {
|
|
753
|
+
return req;
|
|
754
|
+
}
|
|
755
|
+
return "default";
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function applyCodexServiceTierPricing(
|
|
760
|
+
model: Pick<Model<"openai-codex-responses">, "id">,
|
|
761
|
+
usage: AssistantMessage["usage"],
|
|
762
|
+
resTier: unknown,
|
|
763
|
+
reqTier: unknown,
|
|
764
|
+
): void {
|
|
765
|
+
const resolvedTier = resolveCodexCostServiceTier(resTier, reqTier);
|
|
766
|
+
const multiplier = getCodexServiceTierCostMultiplier(model, resolvedTier);
|
|
767
|
+
if (multiplier === 1) return;
|
|
768
|
+
usage.cost.input *= multiplier;
|
|
769
|
+
usage.cost.output *= multiplier;
|
|
770
|
+
usage.cost.cacheRead *= multiplier;
|
|
771
|
+
usage.cost.cacheWrite *= multiplier;
|
|
772
|
+
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function resetOutputState(output: AssistantMessage): void {
|
|
776
|
+
output.content.length = 0;
|
|
777
|
+
output.usage = {
|
|
778
|
+
input: 0,
|
|
779
|
+
output: 0,
|
|
780
|
+
cacheRead: 0,
|
|
781
|
+
cacheWrite: 0,
|
|
782
|
+
totalTokens: 0,
|
|
783
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
784
|
+
};
|
|
785
|
+
output.stopReason = "stop";
|
|
786
|
+
output.stopDetails = undefined;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): CodexRequestSetup {
|
|
790
|
+
const requestAbortController = new AbortController();
|
|
791
|
+
const requestSignal = options?.signal
|
|
792
|
+
? AbortSignal.any([options.signal, requestAbortController.signal])
|
|
793
|
+
: requestAbortController.signal;
|
|
794
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
|
|
795
|
+
const websocketIdleTimeoutMs = options?.streamIdleTimeoutMs ?? CODEX_WEBSOCKET_IDLE_TIMEOUT_MS;
|
|
796
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
797
|
+
const websocketFirstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS;
|
|
798
|
+
const wrapCodexSseStream = (
|
|
799
|
+
source: AsyncGenerator<Record<string, unknown>>,
|
|
800
|
+
): AsyncGenerator<Record<string, unknown>> =>
|
|
801
|
+
iterateWithIdleTimeout(source, {
|
|
802
|
+
idleTimeoutMs,
|
|
803
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
804
|
+
firstItemErrorMessage: "OpenAI Codex SSE stream timed out while waiting for the first event",
|
|
805
|
+
errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
|
|
806
|
+
onIdle: () => requestAbortController.abort(),
|
|
807
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
808
|
+
abortSignal: options?.signal,
|
|
809
|
+
isProgressItem: isCodexStreamProgressEvent,
|
|
810
|
+
});
|
|
811
|
+
return {
|
|
812
|
+
requestAbortController,
|
|
813
|
+
requestSignal,
|
|
814
|
+
wrapCodexSseStream,
|
|
815
|
+
firstEventTimeoutMs,
|
|
816
|
+
websocketIdleTimeoutMs,
|
|
817
|
+
websocketFirstEventTimeoutMs,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function buildCodexRequestContext(
|
|
822
|
+
model: Model<"openai-codex-responses">,
|
|
823
|
+
context: Context,
|
|
824
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
825
|
+
output: AssistantMessage,
|
|
826
|
+
): Promise<CodexRequestContext> {
|
|
827
|
+
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
|
828
|
+
if (!apiKey) {
|
|
829
|
+
throw new AIError.MissingApiKeyError(model.provider);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const accountId = getAccountId(apiKey);
|
|
833
|
+
const baseUrl = model.baseUrl || CODEX_BASE_URL;
|
|
834
|
+
const url = resolveCodexResponsesUrl(baseUrl);
|
|
835
|
+
const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
|
836
|
+
const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
|
837
|
+
const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
|
|
838
|
+
|
|
839
|
+
const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
|
|
840
|
+
const rawRequestDump: RawHttpRequestDump = {
|
|
841
|
+
provider: model.provider,
|
|
842
|
+
api: output.api,
|
|
843
|
+
model: model.id,
|
|
844
|
+
method: "POST",
|
|
845
|
+
url,
|
|
846
|
+
body: transformedBody,
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
850
|
+
const responsesLite = shouldUseCodexResponsesLite(transformedBody, options?.responsesLite);
|
|
851
|
+
const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite);
|
|
852
|
+
const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined;
|
|
853
|
+
if (sessionKey && publicSessionKey) {
|
|
854
|
+
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
855
|
+
}
|
|
856
|
+
const websocketState =
|
|
857
|
+
sessionKey && providerSessionState ? getCodexWebSocketSessionState(sessionKey, providerSessionState) : undefined;
|
|
858
|
+
if (websocketState && !isCodexWithinTurnContinuation(context)) {
|
|
859
|
+
// codex-rs scopes `x-codex-turn-state` to a single user turn: tool-loop
|
|
860
|
+
// follow-ups echo it, a new user turn starts without it.
|
|
861
|
+
websocketState.turnState = undefined;
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
apiKey,
|
|
865
|
+
accountId,
|
|
866
|
+
baseUrl,
|
|
867
|
+
url,
|
|
868
|
+
requestHeaders,
|
|
869
|
+
transportSessionId,
|
|
870
|
+
providerSessionState,
|
|
871
|
+
websocketState,
|
|
872
|
+
responsesLite,
|
|
873
|
+
transformedBody,
|
|
874
|
+
rawRequestDump,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** @internal Exported for tests. */
|
|
879
|
+
export async function buildTransformedCodexRequestBody(
|
|
880
|
+
model: Model<"openai-codex-responses">,
|
|
881
|
+
context: Context,
|
|
882
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
883
|
+
promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
|
|
884
|
+
): Promise<RequestBody> {
|
|
885
|
+
const params: RequestBody = {
|
|
886
|
+
model: model.id,
|
|
887
|
+
input: convertMessages(model, context),
|
|
888
|
+
stream: true,
|
|
889
|
+
prompt_cache_key: promptCacheKey,
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
// `maxTokens` is intentionally not forwarded: transformRequestBody strips
|
|
893
|
+
// `max_output_tokens`/`max_completion_tokens` (the Codex backend rejects
|
|
894
|
+
// caller-supplied output caps). Sampling controls (`temperature`, `top_p`,
|
|
895
|
+
// `top_k`, `min_p`, `presence_penalty`, `repetition_penalty`,
|
|
896
|
+
// `frequency_penalty`, `stop`) are likewise refused with
|
|
897
|
+
// `{"detail":"Unsupported parameter: temperature"}` etc., so we drop
|
|
898
|
+
// everything from `StreamOptions` rather than forwarding any of them.
|
|
899
|
+
// (#3117 — codex-rs sends none of these either.)
|
|
900
|
+
applyOpenAIServiceTier(params, options?.serviceTier, model.provider);
|
|
901
|
+
if (context.tools && context.tools.length > 0) {
|
|
902
|
+
params.tools = convertOpenAICodexResponsesTools(context.tools, model);
|
|
903
|
+
if (options?.toolChoice) {
|
|
904
|
+
const toolChoice = normalizeCodexToolChoice(options.toolChoice, context.tools, model);
|
|
905
|
+
if (toolChoice) {
|
|
906
|
+
params.tool_choice = toolChoice;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
|
912
|
+
if (systemPrompts.length > 0) {
|
|
913
|
+
params.instructions = systemPrompts[0];
|
|
914
|
+
}
|
|
915
|
+
const developerMessages = systemPrompts.slice(1);
|
|
916
|
+
if (options?.clientMetadata && Object.keys(options.clientMetadata).length > 0) {
|
|
917
|
+
params.client_metadata = { ...options.clientMetadata };
|
|
918
|
+
}
|
|
919
|
+
const codexOptions: CodexRequestOptions = {
|
|
920
|
+
reasoningEffort: options?.reasoning,
|
|
921
|
+
reasoningSummary: options?.reasoningSummary === undefined ? "auto" : options.reasoningSummary,
|
|
922
|
+
reasoningContext: options?.reasoningContext,
|
|
923
|
+
textVerbosity: options?.textVerbosity,
|
|
924
|
+
include: options?.include,
|
|
925
|
+
responsesLite: options?.responsesLite,
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
return transformRequestBody(params, model, codexOptions, { developerMessages });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function openInitialCodexEventStream(
|
|
932
|
+
model: Model<"openai-codex-responses">,
|
|
933
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
934
|
+
requestSetup: CodexRequestSetup,
|
|
935
|
+
requestContext: CodexRequestContext,
|
|
936
|
+
): Promise<{
|
|
937
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
938
|
+
requestBodyForState: RequestBody;
|
|
939
|
+
transport: CodexTransport;
|
|
940
|
+
}> {
|
|
941
|
+
const { transformedBody, websocketState } = requestContext;
|
|
942
|
+
if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) {
|
|
943
|
+
const websocketRetryBudget = CODEX_WEBSOCKET_RETRY_BUDGET;
|
|
944
|
+
let websocketRetries = 0;
|
|
945
|
+
while (true) {
|
|
946
|
+
try {
|
|
947
|
+
return await openCodexWebSocketTransport(
|
|
948
|
+
model,
|
|
949
|
+
options,
|
|
950
|
+
requestContext,
|
|
951
|
+
requestSetup,
|
|
952
|
+
websocketState,
|
|
953
|
+
websocketRetries,
|
|
954
|
+
options ? event => options.onSseEvent?.(event, model) : undefined,
|
|
955
|
+
);
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (!(error instanceof CodexWebSocketTransportError)) throw error;
|
|
958
|
+
const fatalWebSocketMessage = error.message.toLowerCase();
|
|
959
|
+
const isFatal = CODEX_WEBSOCKET_FATAL_PATTERNS.some(pattern =>
|
|
960
|
+
fatalWebSocketMessage.includes(pattern.toLowerCase()),
|
|
961
|
+
);
|
|
962
|
+
const activateFallback = isFatal || websocketRetries >= websocketRetryBudget;
|
|
963
|
+
recordCodexWebSocketFailure(websocketState, activateFallback);
|
|
964
|
+
CODEX_DEBUG &&
|
|
965
|
+
logger.debug("[codex] codex websocket fallback", {
|
|
966
|
+
error: error.message,
|
|
967
|
+
retry: websocketRetries,
|
|
968
|
+
retryBudget: websocketRetryBudget,
|
|
969
|
+
activated: activateFallback,
|
|
970
|
+
fatal: isFatal,
|
|
971
|
+
});
|
|
972
|
+
if (!activateFallback) {
|
|
973
|
+
websocketRetries += 1;
|
|
974
|
+
await scheduler.wait(CODEX_WEBSOCKET_RETRY_DELAY_MS * Math.max(1, websocketRetries), {
|
|
975
|
+
signal: requestSetup.requestSignal,
|
|
976
|
+
});
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
|
|
984
|
+
}
|
|
985
|
+
async function openCodexWebSocketTransport(
|
|
986
|
+
model: Model<"openai-codex-responses">,
|
|
987
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
988
|
+
requestContext: CodexRequestContext,
|
|
989
|
+
requestSetup: CodexRequestSetup,
|
|
990
|
+
websocketState: CodexWebSocketSessionState,
|
|
991
|
+
retry: number,
|
|
992
|
+
onSseEvent?: (event: RawSseEvent) => void,
|
|
993
|
+
): Promise<{
|
|
994
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
995
|
+
requestBodyForState: RequestBody;
|
|
996
|
+
transport: CodexTransport;
|
|
997
|
+
}> {
|
|
998
|
+
const chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
|
|
999
|
+
// WebSocket frames cannot carry per-request HTTP headers, so the Responses
|
|
1000
|
+
// Lite marker rides in `client_metadata` on every `response.create`.
|
|
1001
|
+
let websocketRequest = {
|
|
1002
|
+
type: "response.create",
|
|
1003
|
+
...chainedBody,
|
|
1004
|
+
...(requestContext.responsesLite
|
|
1005
|
+
? {
|
|
1006
|
+
client_metadata: {
|
|
1007
|
+
...(chainedBody.client_metadata ?? {}),
|
|
1008
|
+
[CODEX_WS_RESPONSES_LITE_CLIENT_METADATA_KEY]: "true",
|
|
1009
|
+
},
|
|
1010
|
+
}
|
|
1011
|
+
: {}),
|
|
1012
|
+
};
|
|
1013
|
+
const replacementWebsocketRequest = await options?.onPayload?.(websocketRequest, model);
|
|
1014
|
+
if (replacementWebsocketRequest !== undefined) {
|
|
1015
|
+
websocketRequest = replacementWebsocketRequest as typeof websocketRequest;
|
|
1016
|
+
}
|
|
1017
|
+
const websocketHeaders = createCodexHeaders(
|
|
1018
|
+
requestContext.requestHeaders,
|
|
1019
|
+
requestContext.accountId,
|
|
1020
|
+
requestContext.apiKey,
|
|
1021
|
+
requestContext.transportSessionId,
|
|
1022
|
+
"websocket",
|
|
1023
|
+
websocketState,
|
|
1024
|
+
requestContext.responsesLite,
|
|
1025
|
+
);
|
|
1026
|
+
const requestBodyForState = structuredCloneJSON(requestContext.transformedBody);
|
|
1027
|
+
requestContext.rawRequestDump.body = websocketRequest;
|
|
1028
|
+
CODEX_DEBUG &&
|
|
1029
|
+
logger.debug("[codex] codex websocket request", {
|
|
1030
|
+
url: toWebSocketUrl(requestContext.url),
|
|
1031
|
+
model: requestContext.transformedBody.model,
|
|
1032
|
+
reasoningEffort: requestContext.transformedBody.reasoning?.effort ?? null,
|
|
1033
|
+
headers: redactHeaders(websocketHeaders),
|
|
1034
|
+
sentTurnStateHeader: websocketHeaders.has(X_CODEX_TURN_STATE_HEADER),
|
|
1035
|
+
sentModelsEtagHeader: websocketHeaders.has(X_MODELS_ETAG_HEADER),
|
|
1036
|
+
requestType: websocketRequest.type,
|
|
1037
|
+
retry,
|
|
1038
|
+
retryBudget: CODEX_WEBSOCKET_RETRY_BUDGET,
|
|
1039
|
+
});
|
|
1040
|
+
const websocketConnection = await getOrCreateCodexWebSocketConnection(
|
|
1041
|
+
websocketState,
|
|
1042
|
+
toWebSocketUrl(requestContext.url),
|
|
1043
|
+
websocketHeaders,
|
|
1044
|
+
requestSetup.requestSignal,
|
|
1045
|
+
);
|
|
1046
|
+
const eventStream = websocketConnection.streamRequest(
|
|
1047
|
+
websocketRequest,
|
|
1048
|
+
{
|
|
1049
|
+
idleTimeoutMs: requestSetup.websocketIdleTimeoutMs,
|
|
1050
|
+
firstEventTimeoutMs: requestSetup.websocketFirstEventTimeoutMs,
|
|
1051
|
+
},
|
|
1052
|
+
requestSetup.requestSignal,
|
|
1053
|
+
onSseEvent,
|
|
1054
|
+
);
|
|
1055
|
+
return {
|
|
1056
|
+
eventStream,
|
|
1057
|
+
requestBodyForState,
|
|
1058
|
+
transport: "websocket",
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* True when the request continues the current turn (everything after the
|
|
1064
|
+
* last assistant message is tool results), false when a new user turn starts.
|
|
1065
|
+
* Mirrors codex-rs, which scopes `x-codex-turn-state` to a single turn and
|
|
1066
|
+
* clears it when the next one begins.
|
|
1067
|
+
*/
|
|
1068
|
+
function isCodexWithinTurnContinuation(context: Context): boolean {
|
|
1069
|
+
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
1070
|
+
const role = context.messages[i]?.role;
|
|
1071
|
+
if (role === "toolResult") continue;
|
|
1072
|
+
return role === "assistant";
|
|
1073
|
+
}
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function openCodexSseTransport(
|
|
1078
|
+
model: Model<"openai-codex-responses">,
|
|
1079
|
+
requestContext: CodexRequestContext,
|
|
1080
|
+
requestSetup: CodexRequestSetup,
|
|
1081
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
1082
|
+
state: CodexWebSocketSessionState | undefined,
|
|
1083
|
+
body = requestContext.transformedBody,
|
|
1084
|
+
): Promise<{
|
|
1085
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
1086
|
+
requestBodyForState: RequestBody;
|
|
1087
|
+
transport: CodexTransport;
|
|
1088
|
+
}> {
|
|
1089
|
+
const open = async (wireBody: RequestBody) => {
|
|
1090
|
+
// Keep the 400 dump honest: record the body actually sent on the wire.
|
|
1091
|
+
requestContext.rawRequestDump.body = wireBody;
|
|
1092
|
+
return requestSetup.wrapCodexSseStream(
|
|
1093
|
+
await openCodexSseEventStream(
|
|
1094
|
+
requestContext.url,
|
|
1095
|
+
requestContext.requestHeaders,
|
|
1096
|
+
requestContext.accountId,
|
|
1097
|
+
requestContext.apiKey,
|
|
1098
|
+
requestContext.transportSessionId,
|
|
1099
|
+
wireBody,
|
|
1100
|
+
state,
|
|
1101
|
+
requestContext.responsesLite,
|
|
1102
|
+
requestSetup.requestSignal,
|
|
1103
|
+
requestSetup.firstEventTimeoutMs,
|
|
1104
|
+
event => options?.onSseEvent?.(event, model),
|
|
1105
|
+
options?.fetch,
|
|
1106
|
+
),
|
|
1107
|
+
);
|
|
1108
|
+
};
|
|
1109
|
+
let wireBody = body;
|
|
1110
|
+
const replacementWireBody = await options?.onPayload?.(wireBody, model);
|
|
1111
|
+
if (replacementWireBody !== undefined) {
|
|
1112
|
+
wireBody = replacementWireBody as RequestBody;
|
|
1113
|
+
}
|
|
1114
|
+
recordCodexWebSocketRequestStats(state, wireBody);
|
|
1115
|
+
return { eventStream: await open(wireBody), requestBodyForState: structuredCloneJSON(wireBody), transport: "sse" };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function isJsonWhitespaceOnly(value: string): boolean {
|
|
1119
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1120
|
+
const code = value.charCodeAt(index);
|
|
1121
|
+
if (code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x20) {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null {
|
|
1129
|
+
if (item.type === "reasoning") {
|
|
1130
|
+
return { type: "thinking", thinking: "" };
|
|
1131
|
+
}
|
|
1132
|
+
if (item.type === "message") {
|
|
1133
|
+
const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
|
|
1134
|
+
return { type: "text", text: "", textSignature: encodeTextSignatureV1(item.id, phase) };
|
|
1135
|
+
}
|
|
1136
|
+
if (item.type === "function_call") {
|
|
1137
|
+
return {
|
|
1138
|
+
type: "toolCall",
|
|
1139
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1140
|
+
name: item.name,
|
|
1141
|
+
arguments: {},
|
|
1142
|
+
[kStreamingPartialJson]: item.arguments || "",
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
if (item.type === "custom_tool_call") {
|
|
1146
|
+
// Wire name flows through unchanged; the agent-loop dispatcher also
|
|
1147
|
+
// matches `Tool.customWireName`. Reuse `partialJson` as the
|
|
1148
|
+
// accumulation buffer for the raw input string.
|
|
1149
|
+
return {
|
|
1150
|
+
type: "toolCall",
|
|
1151
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1152
|
+
name: item.name,
|
|
1153
|
+
arguments: { input: item.input ?? "" },
|
|
1154
|
+
customWireName: item.name,
|
|
1155
|
+
[kStreamingPartialJson]: item.input ?? "",
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start" | "text_start" | "toolcall_start" {
|
|
1162
|
+
if (block.type === "thinking") return "thinking_start";
|
|
1163
|
+
if (block.type === "text") return "text_start";
|
|
1164
|
+
return "toolcall_start";
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function isCodexStalePreviousResponseError(error: unknown): boolean {
|
|
1168
|
+
if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
|
|
1169
|
+
if (!(error instanceof Error)) return false;
|
|
1170
|
+
if ((error as { code?: string }).code === "previous_response_not_found") return true;
|
|
1171
|
+
// "unsupported": the backend intermittently rejects the parameter outright
|
|
1172
|
+
// with `{"detail":"Unsupported parameter: previous_response_id"}` (no
|
|
1173
|
+
// `error.code`); treat it like a stale chain so the turn replays with full
|
|
1174
|
+
// context instead of surfacing the 400.
|
|
1175
|
+
return (
|
|
1176
|
+
/previous[ _]?response/i.test(error.message) &&
|
|
1177
|
+
/not[ _]?found|invalid|expired|stale|unsupported/i.test(error.message)
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async function handleCodexStreamFailure(context: CodexStreamFailureContext, error: unknown): Promise<AssistantMessage> {
|
|
1182
|
+
const { output } = context;
|
|
1183
|
+
if (context.requestContext.websocketState) {
|
|
1184
|
+
resetCodexWebSocketAppendState(context.requestContext.websocketState);
|
|
1185
|
+
context.requestContext.websocketState.turnState = undefined;
|
|
1186
|
+
context.requestContext.websocketState.modelsEtag = undefined;
|
|
1187
|
+
}
|
|
1188
|
+
const result = await AIError.finalize(error, {
|
|
1189
|
+
api: context.model.api,
|
|
1190
|
+
signal: context.options?.signal,
|
|
1191
|
+
rawRequestDump: context.requestContext.rawRequestDump,
|
|
1192
|
+
});
|
|
1193
|
+
output.stopReason = result.stopReason;
|
|
1194
|
+
output.errorStatus = result.status;
|
|
1195
|
+
output.errorId = result.id;
|
|
1196
|
+
output.errorMessage = result.message;
|
|
1197
|
+
output.duration = performance.now() - context.startTime;
|
|
1198
|
+
if (context.firstTokenTime) {
|
|
1199
|
+
output.ttft = context.firstTokenTime - context.startTime;
|
|
1200
|
+
}
|
|
1201
|
+
return output;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Owns one `streamOpenAICodexResponses` call: the request scaffolding
|
|
1206
|
+
* (model/output/stream/options/request context) plus the per-attempt
|
|
1207
|
+
* {@link CodexStreamRuntime}. Drives the event loop in {@link process}, applies
|
|
1208
|
+
* the transport-fallback / retry recovery ladder, and emits the final message
|
|
1209
|
+
* in {@link finalize}. The runtime object is mutated in place across retries
|
|
1210
|
+
* (event stream and accumulators are swapped/reset), never reassigned.
|
|
1211
|
+
*/
|
|
1212
|
+
class CodexStreamProcessor {
|
|
1213
|
+
runtime: CodexStreamRuntime;
|
|
1214
|
+
model: Model<"openai-codex-responses">;
|
|
1215
|
+
output: AssistantMessage;
|
|
1216
|
+
stream: AssistantMessageEventStream;
|
|
1217
|
+
options: OpenAICodexResponsesOptions | undefined;
|
|
1218
|
+
requestSetup: CodexRequestSetup;
|
|
1219
|
+
requestContext: CodexRequestContext;
|
|
1220
|
+
startTime: number;
|
|
1221
|
+
firstTokenTime?: number;
|
|
1222
|
+
|
|
1223
|
+
constructor(init: {
|
|
1224
|
+
runtime: CodexStreamRuntime;
|
|
1225
|
+
model: Model<"openai-codex-responses">;
|
|
1226
|
+
output: AssistantMessage;
|
|
1227
|
+
stream: AssistantMessageEventStream;
|
|
1228
|
+
options: OpenAICodexResponsesOptions | undefined;
|
|
1229
|
+
requestSetup: CodexRequestSetup;
|
|
1230
|
+
requestContext: CodexRequestContext;
|
|
1231
|
+
startTime: number;
|
|
1232
|
+
}) {
|
|
1233
|
+
this.runtime = init.runtime;
|
|
1234
|
+
this.model = init.model;
|
|
1235
|
+
this.output = init.output;
|
|
1236
|
+
this.stream = init.stream;
|
|
1237
|
+
this.options = init.options;
|
|
1238
|
+
this.requestSetup = init.requestSetup;
|
|
1239
|
+
this.requestContext = init.requestContext;
|
|
1240
|
+
this.startTime = init.startTime;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async process(): Promise<CodexStreamCompletion> {
|
|
1244
|
+
const { output, stream } = this;
|
|
1245
|
+
stream.push({ type: "start", partial: output });
|
|
1246
|
+
|
|
1247
|
+
while (true) {
|
|
1248
|
+
try {
|
|
1249
|
+
let firstTokenTime = this.firstTokenTime;
|
|
1250
|
+
for await (const rawEvent of this.runtime.eventStream) {
|
|
1251
|
+
firstTokenTime = this.#handleStreamEvent(rawEvent, firstTokenTime);
|
|
1252
|
+
if (this.runtime.sawTerminalEvent) break;
|
|
1253
|
+
}
|
|
1254
|
+
return { firstTokenTime };
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
const recovered = await this.#recoverStreamError(error);
|
|
1257
|
+
if (!recovered) {
|
|
1258
|
+
throw error;
|
|
1259
|
+
}
|
|
1260
|
+
stream.push({ type: "start", partial: output });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
#handleStreamEvent(rawEvent: Record<string, unknown>, firstTokenTime: number | undefined): number | undefined {
|
|
1266
|
+
const { output, stream } = this;
|
|
1267
|
+
const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
|
|
1268
|
+
if (!eventType) return firstTokenTime;
|
|
1269
|
+
|
|
1270
|
+
if (eventType === "response.output_item.added") {
|
|
1271
|
+
this.runtime.whitespaceToolCallArgumentsDelta = undefined;
|
|
1272
|
+
if (!firstTokenTime) firstTokenTime = performance.now();
|
|
1273
|
+
const item = rawEvent.item as CodexEventItem;
|
|
1274
|
+
this.runtime.currentItem = item;
|
|
1275
|
+
this.runtime.currentBlock = createOutputBlockForItem(item);
|
|
1276
|
+
let contentIndex = -1;
|
|
1277
|
+
if (this.runtime.currentBlock) {
|
|
1278
|
+
output.content.push(this.runtime.currentBlock);
|
|
1279
|
+
contentIndex = output.content.length - 1;
|
|
1280
|
+
}
|
|
1281
|
+
// Track every open item by every stable key the wire gives us. `item.id`
|
|
1282
|
+
// is best; `output_index` preserves idless function/custom tool calls and
|
|
1283
|
+
// keeps their final args authoritative when only `output_item.done`
|
|
1284
|
+
// carries the full payload.
|
|
1285
|
+
const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : undefined;
|
|
1286
|
+
const outputIndex =
|
|
1287
|
+
typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
|
|
1288
|
+
? Math.trunc(rawEvent.output_index)
|
|
1289
|
+
: undefined;
|
|
1290
|
+
const entry: CodexOpenItem = { item, block: this.runtime.currentBlock, contentIndex, itemId, outputIndex };
|
|
1291
|
+
this.runtime.currentEntry = entry;
|
|
1292
|
+
if (itemId) this.runtime.openItems.set(itemId, entry);
|
|
1293
|
+
if (outputIndex !== undefined) this.runtime.openItemsByOutputIndex.set(outputIndex, entry);
|
|
1294
|
+
if (!this.runtime.currentBlock) return firstTokenTime;
|
|
1295
|
+
stream.push({
|
|
1296
|
+
type: getOutputBlockStartEventType(this.runtime.currentBlock),
|
|
1297
|
+
contentIndex,
|
|
1298
|
+
partial: output,
|
|
1299
|
+
});
|
|
1300
|
+
return firstTokenTime;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (eventType === "response.reasoning_summary_part.added") {
|
|
1304
|
+
if (this.runtime.currentItem?.type === "reasoning") {
|
|
1305
|
+
appendReasoningSummaryPart(
|
|
1306
|
+
this.runtime.currentItem,
|
|
1307
|
+
(rawEvent as { part: ResponseReasoningItem["summary"][number] }).part,
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
return firstTokenTime;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (eventType === "response.reasoning_summary_text.delta") {
|
|
1314
|
+
if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
|
|
1315
|
+
appendReasoningSummaryTextDelta(
|
|
1316
|
+
this.runtime.currentItem,
|
|
1317
|
+
this.runtime.currentBlock,
|
|
1318
|
+
(rawEvent as { delta?: string }).delta || "",
|
|
1319
|
+
stream,
|
|
1320
|
+
output,
|
|
1321
|
+
output.content.length - 1,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
return firstTokenTime;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (eventType === "response.reasoning_summary_part.done") {
|
|
1328
|
+
if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
|
|
1329
|
+
appendReasoningSummaryPartDone(
|
|
1330
|
+
this.runtime.currentItem,
|
|
1331
|
+
this.runtime.currentBlock,
|
|
1332
|
+
stream,
|
|
1333
|
+
output,
|
|
1334
|
+
output.content.length - 1,
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
return firstTokenTime;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (eventType === "response.content_part.added") {
|
|
1341
|
+
if (this.runtime.currentItem?.type === "message") {
|
|
1342
|
+
appendMessageContentPart(
|
|
1343
|
+
this.runtime.currentItem,
|
|
1344
|
+
(rawEvent as { part?: ResponseOutputMessage["content"][number] }).part,
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
return firstTokenTime;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
if (eventType === "response.output_text.delta" || eventType === "response.refusal.delta") {
|
|
1351
|
+
if (this.runtime.currentItem?.type === "message" && this.runtime.currentBlock?.type === "text") {
|
|
1352
|
+
appendMessageTextDelta(
|
|
1353
|
+
this.runtime.currentItem,
|
|
1354
|
+
this.runtime.currentBlock,
|
|
1355
|
+
(rawEvent as { delta?: string }).delta || "",
|
|
1356
|
+
stream,
|
|
1357
|
+
output,
|
|
1358
|
+
output.content.length - 1,
|
|
1359
|
+
eventType === "response.refusal.delta" ? "refusal" : "output_text",
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
return firstTokenTime;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (eventType === "response.function_call_arguments.delta") {
|
|
1366
|
+
const interruption = this.runtime.handleToolCallArgumentsDelta(rawEvent, stream, output);
|
|
1367
|
+
if (interruption) {
|
|
1368
|
+
this.runtime.websocketState?.connection?.close("degenerate-tool-call");
|
|
1369
|
+
throw new CodexWhitespaceToolCallLoopError(interruption.message);
|
|
1370
|
+
}
|
|
1371
|
+
return firstTokenTime;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (eventType === "response.function_call_arguments.done") {
|
|
1375
|
+
this.runtime.whitespaceToolCallArgumentsDelta = undefined;
|
|
1376
|
+
this.runtime.handleToolCallArgumentsDone(rawEvent);
|
|
1377
|
+
return firstTokenTime;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (eventType === "response.custom_tool_call_input.delta") {
|
|
1381
|
+
const interruption = this.runtime.handleCustomToolCallInputDelta(rawEvent, stream, output);
|
|
1382
|
+
if (interruption) {
|
|
1383
|
+
this.runtime.websocketState?.connection?.close("degenerate-tool-call");
|
|
1384
|
+
throw new CodexWhitespaceToolCallLoopError(interruption.message);
|
|
1385
|
+
}
|
|
1386
|
+
return firstTokenTime;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (eventType === "response.custom_tool_call_input.done") {
|
|
1390
|
+
this.runtime.whitespaceToolCallArgumentsDelta = undefined;
|
|
1391
|
+
this.runtime.handleCustomToolCallInputDone(rawEvent);
|
|
1392
|
+
return firstTokenTime;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (eventType === "response.output_item.done") {
|
|
1396
|
+
this.runtime.whitespaceToolCallArgumentsDelta = undefined;
|
|
1397
|
+
this.#handleOutputItemDone(rawEvent);
|
|
1398
|
+
return firstTokenTime;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (eventType === "response.created") {
|
|
1402
|
+
this.runtime.handleResponseCreated(rawEvent);
|
|
1403
|
+
return firstTokenTime;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
|
|
1407
|
+
this.#handleResponseCompleted(rawEvent);
|
|
1408
|
+
return firstTokenTime;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (eventType === "response.metadata") {
|
|
1412
|
+
const moderation = asRecord(rawEvent.metadata)?.[CODEX_MODERATION_METADATA_KEY];
|
|
1413
|
+
if (moderation !== undefined) {
|
|
1414
|
+
try {
|
|
1415
|
+
this.options?.onModerationMetadata?.(moderation);
|
|
1416
|
+
} catch {
|
|
1417
|
+
// Diagnostic observer: failures must not disturb the stream.
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return firstTokenTime;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (eventType === "error" || eventType === "response.failed") {
|
|
1424
|
+
throw createCodexProviderStreamError(rawEvent);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return firstTokenTime;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
#handleOutputItemDone(rawEvent: Record<string, unknown>): void {
|
|
1431
|
+
const { runtime, output, stream } = this;
|
|
1432
|
+
const rawItem = rawEvent.item;
|
|
1433
|
+
if (!rawItem || typeof rawItem !== "object") return;
|
|
1434
|
+
const item = structuredCloneJSON(rawItem) as CodexEventItem;
|
|
1435
|
+
runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
|
|
1436
|
+
|
|
1437
|
+
// Match the finalization to the OPEN ITEM that started this block, not the
|
|
1438
|
+
// singleton current — interleaved items can finish out of order, so the
|
|
1439
|
+
// most-recently-added block may belong to a sibling (#2619). Some Codex
|
|
1440
|
+
// function/custom tool items omit `id`; in that case `output_index` still
|
|
1441
|
+
// routes `output_item.done` to the block that received `output_item.added`.
|
|
1442
|
+
const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : "";
|
|
1443
|
+
const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? runtime.openItemForEvent(rawEvent);
|
|
1444
|
+
const block = entry?.block ?? null;
|
|
1445
|
+
const contentIndex = entry?.contentIndex ?? output.content.length - 1;
|
|
1446
|
+
|
|
1447
|
+
if (item.type === "reasoning" && block?.type === "thinking") {
|
|
1448
|
+
block.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
|
|
1449
|
+
block.thinkingSignature = JSON.stringify(item);
|
|
1450
|
+
stream.push({
|
|
1451
|
+
type: "thinking_end",
|
|
1452
|
+
contentIndex,
|
|
1453
|
+
content: block.thinking,
|
|
1454
|
+
partial: output,
|
|
1455
|
+
});
|
|
1456
|
+
runtime.closeOpenItem(entry);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (item.type === "message" && block?.type === "text") {
|
|
1461
|
+
block.text = item.content
|
|
1462
|
+
.map(content => (content.type === "output_text" ? content.text : content.refusal))
|
|
1463
|
+
.join("");
|
|
1464
|
+
const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
|
|
1465
|
+
block.textSignature = encodeTextSignatureV1(item.id, phase);
|
|
1466
|
+
stream.push({
|
|
1467
|
+
type: "text_end",
|
|
1468
|
+
contentIndex,
|
|
1469
|
+
content: block.text,
|
|
1470
|
+
partial: output,
|
|
1471
|
+
});
|
|
1472
|
+
runtime.closeOpenItem(entry);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (item.type === "function_call") {
|
|
1477
|
+
const toolCall: ToolCall = {
|
|
1478
|
+
type: "toolCall",
|
|
1479
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1480
|
+
name: item.name,
|
|
1481
|
+
arguments: parseStreamingJson(item.arguments || "{}"),
|
|
1482
|
+
};
|
|
1483
|
+
if (block?.type === "toolCall") {
|
|
1484
|
+
// Persist the authoritative final args on the stored block; the throttled
|
|
1485
|
+
// delta parser may have left block.arguments stale (often `{}`).
|
|
1486
|
+
block.arguments = toolCall.arguments;
|
|
1487
|
+
clearStreamingPartialJson(block);
|
|
1488
|
+
}
|
|
1489
|
+
// Detach so a late/duplicate arguments.delta cannot append to the
|
|
1490
|
+
// finished block or trip the whitespace-loop guard against it.
|
|
1491
|
+
runtime.closeOpenItem(entry);
|
|
1492
|
+
runtime.canSafelyReplayWebsocketOverSse = false;
|
|
1493
|
+
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
if (item.type === "custom_tool_call") {
|
|
1498
|
+
const partial = block?.type === "toolCall" ? block[kStreamingPartialJson] : undefined;
|
|
1499
|
+
const rawInput = partial && partial.length > 0 ? partial : (item.input ?? "");
|
|
1500
|
+
const toolCall: ToolCall = {
|
|
1501
|
+
type: "toolCall",
|
|
1502
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1503
|
+
name: item.name,
|
|
1504
|
+
arguments: { input: rawInput },
|
|
1505
|
+
customWireName: item.name,
|
|
1506
|
+
};
|
|
1507
|
+
if (block?.type === "toolCall") {
|
|
1508
|
+
block.arguments = { input: rawInput };
|
|
1509
|
+
clearStreamingPartialJson(block);
|
|
1510
|
+
}
|
|
1511
|
+
runtime.closeOpenItem(entry);
|
|
1512
|
+
runtime.canSafelyReplayWebsocketOverSse = false;
|
|
1513
|
+
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
#handleResponseCompleted(rawEvent: Record<string, unknown>): void {
|
|
1519
|
+
const { runtime, model, output } = this;
|
|
1520
|
+
runtime.sawTerminalEvent = true;
|
|
1521
|
+
const response = (
|
|
1522
|
+
rawEvent as {
|
|
1523
|
+
response?: {
|
|
1524
|
+
id?: string;
|
|
1525
|
+
usage?: {
|
|
1526
|
+
input_tokens?: number;
|
|
1527
|
+
output_tokens?: number;
|
|
1528
|
+
total_tokens?: number;
|
|
1529
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
1530
|
+
output_tokens_details?: { reasoning_tokens?: number };
|
|
1531
|
+
};
|
|
1532
|
+
status?: string;
|
|
1533
|
+
service_tier?: ServiceTier | "default";
|
|
1534
|
+
end_turn?: boolean;
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
).response;
|
|
1538
|
+
|
|
1539
|
+
populateResponsesUsageFromResponse(output, response?.usage);
|
|
1540
|
+
if (typeof response?.id === "string" && response.id.length > 0) {
|
|
1541
|
+
output.responseId = response.id;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const state = runtime.websocketState;
|
|
1545
|
+
if (state) {
|
|
1546
|
+
if (runtime.transport !== "websocket") {
|
|
1547
|
+
// SSE turns never chain (previous_response_id is websocket-only on this
|
|
1548
|
+
// endpoint); a completed SSE turn also invalidates any websocket append
|
|
1549
|
+
// baseline, which no longer matches the transcript.
|
|
1550
|
+
resetCodexWebSocketAppendState(state);
|
|
1551
|
+
} else {
|
|
1552
|
+
state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
|
|
1553
|
+
if (typeof response?.id === "string" && response.id.length > 0) {
|
|
1554
|
+
state.lastResponseId = response.id;
|
|
1555
|
+
state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
|
|
1556
|
+
state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
|
|
1557
|
+
} else {
|
|
1558
|
+
// Without a response id the append baseline cannot be trusted.
|
|
1559
|
+
state.canAppend = false;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
finalizePendingResponsesToolCalls(output);
|
|
1565
|
+
|
|
1566
|
+
calculateCost(model, output.usage);
|
|
1567
|
+
applyCodexServiceTierPricing(
|
|
1568
|
+
model,
|
|
1569
|
+
output.usage,
|
|
1570
|
+
response?.service_tier,
|
|
1571
|
+
runtime.requestBodyForState.service_tier,
|
|
1572
|
+
);
|
|
1573
|
+
output.stopReason = mapOpenAIResponsesStopReason(response?.status as ResponseStatus | undefined);
|
|
1574
|
+
promoteResponsesToolUseStopReason(output, response?.end_turn);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async #recoverStreamError(error: unknown): Promise<boolean> {
|
|
1578
|
+
if (await this.#tryRecoverWhitespaceToolCallLoop(error)) {
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
if (await this.#tryReconnectWebSocketOnConnectionLimit(error)) {
|
|
1582
|
+
return true;
|
|
1583
|
+
}
|
|
1584
|
+
if (await this.#tryRecoverPreviousResponseNotFound(error)) {
|
|
1585
|
+
return true;
|
|
1586
|
+
}
|
|
1587
|
+
if (await this.#tryReplayWebsocketFailureOverSse(error)) {
|
|
1588
|
+
return true;
|
|
1589
|
+
}
|
|
1590
|
+
if (await this.#tryRetryProviderError(error)) {
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1593
|
+
return false;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Recover from the degenerate whitespace-only tool-call argument loop
|
|
1598
|
+
* ({@link CodexWhitespaceToolCallLoopError}). The interrupted function call has
|
|
1599
|
+
* no usable arguments, so drop the partial turn and replay the request from
|
|
1600
|
+
* scratch — bounded by {@link CODEX_WHITESPACE_LOOP_RETRY_LIMIT}. Sampling
|
|
1601
|
+
* nondeterminism usually breaks the loop on a fresh attempt; once the budget is
|
|
1602
|
+
* exhausted the original error is surfaced (now without the junk tool call
|
|
1603
|
+
* polluting the message). Replay is refused once any visible content was already
|
|
1604
|
+
* delivered to the consumer — a finished tool call (`canSafelyReplayWebsocketOverSse`),
|
|
1605
|
+
* or any streamed text/commentary block still in `output.content` after the degenerate
|
|
1606
|
+
* tool call is dropped — because replaying re-emits already-streamed deltas.
|
|
1607
|
+
*/
|
|
1608
|
+
async #tryRecoverWhitespaceToolCallLoop(error: unknown): Promise<boolean> {
|
|
1609
|
+
if (!(error instanceof CodexWhitespaceToolCallLoopError)) {
|
|
1610
|
+
return false;
|
|
1611
|
+
}
|
|
1612
|
+
// Drop the half-built degenerate tool call whether or not we retry, so it
|
|
1613
|
+
// never reaches the caller's message.
|
|
1614
|
+
this.#dropTrailingDegenerateToolCall();
|
|
1615
|
+
if (
|
|
1616
|
+
this.runtime.whitespaceLoopRetries >= CODEX_WHITESPACE_LOOP_RETRY_LIMIT ||
|
|
1617
|
+
!this.runtime.canSafelyReplayWebsocketOverSse ||
|
|
1618
|
+
this.output.content.some(block => block.type !== "thinking") ||
|
|
1619
|
+
this.options?.signal?.aborted
|
|
1620
|
+
) {
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
this.runtime.whitespaceLoopRetries += 1;
|
|
1625
|
+
const websocketState = this.requestContext.websocketState;
|
|
1626
|
+
if (websocketState) {
|
|
1627
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1628
|
+
websocketState.turnState = undefined;
|
|
1629
|
+
websocketState.modelsEtag = undefined;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
CODEX_DEBUG &&
|
|
1633
|
+
logger.debug("[codex] retrying codex turn after whitespace-only tool-call argument loop", {
|
|
1634
|
+
retry: this.runtime.whitespaceLoopRetries,
|
|
1635
|
+
retryBudget: CODEX_WHITESPACE_LOOP_RETRY_LIMIT,
|
|
1636
|
+
transport: this.runtime.transport,
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
this.runtime.resetAccumulators();
|
|
1640
|
+
this.runtime.sawTerminalEvent = false;
|
|
1641
|
+
this.runtime.whitespaceToolCallArgumentsDelta = undefined;
|
|
1642
|
+
resetOutputState(this.output);
|
|
1643
|
+
this.firstTokenTime = undefined;
|
|
1644
|
+
await scheduler.wait(CODEX_WHITESPACE_LOOP_RETRY_DELAY_MS * this.runtime.whitespaceLoopRetries, {
|
|
1645
|
+
signal: this.requestSetup.requestSignal,
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
if (this.runtime.transport === "websocket" && websocketState) {
|
|
1649
|
+
await this.#reopenWebSocketStream(websocketState);
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
await this.#reopenSseStream(websocketState);
|
|
1654
|
+
return true;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Pop the half-built degenerate tool-call block (the one whose arguments were
|
|
1659
|
+
* nothing but whitespace) off the output accumulator so it never surfaces in the
|
|
1660
|
+
* caller's message. Any legitimate content produced before it is preserved.
|
|
1661
|
+
*/
|
|
1662
|
+
#dropTrailingDegenerateToolCall(): void {
|
|
1663
|
+
const { runtime, output } = this;
|
|
1664
|
+
const block = runtime.currentBlock;
|
|
1665
|
+
if (block && block.type === "toolCall" && output.content[output.content.length - 1] === block) {
|
|
1666
|
+
output.content.pop();
|
|
1667
|
+
}
|
|
1668
|
+
runtime.closeOpenItem(runtime.currentEntry);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Handles `websocket_connection_limit_reached` errors by closing the stale connection
|
|
1673
|
+
* and opening a fresh websocket. If content has already been emitted to the caller,
|
|
1674
|
+
* falls back to SSE replay (same as other WS failures) since we cannot safely
|
|
1675
|
+
* continue a partial response on a new connection. If a tool call was already
|
|
1676
|
+
* delivered (`canSafelyReplayWebsocketOverSse` is false), the error surfaces
|
|
1677
|
+
* instead — replaying would re-emit the same tool calls.
|
|
1678
|
+
*/
|
|
1679
|
+
async #tryReconnectWebSocketOnConnectionLimit(error: unknown): Promise<boolean> {
|
|
1680
|
+
if (!(error instanceof CodexProviderStreamError) || error.code !== "websocket_connection_limit_reached") {
|
|
1681
|
+
return false;
|
|
1682
|
+
}
|
|
1683
|
+
const websocketState = this.requestContext.websocketState;
|
|
1684
|
+
if (!websocketState || this.runtime.transport !== "websocket" || this.options?.signal?.aborted) {
|
|
1685
|
+
return false;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Close the stale connection so getOrCreateCodexWebSocketConnection creates a fresh one.
|
|
1689
|
+
websocketState.connection?.close("connection_limit");
|
|
1690
|
+
websocketState.connection = undefined;
|
|
1691
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1692
|
+
|
|
1693
|
+
if (this.output.content.length > 0 && !this.runtime.canSafelyReplayWebsocketOverSse) {
|
|
1694
|
+
// A toolcall_end already reached the consumer; a full replay would emit
|
|
1695
|
+
// the same tool calls a second time. Let the error surface instead.
|
|
1696
|
+
return false;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
CODEX_DEBUG &&
|
|
1700
|
+
logger.debug("[codex] codex websocket connection limit reached, reconnecting", {
|
|
1701
|
+
hadContent: this.output.content.length > 0,
|
|
1702
|
+
retry: this.runtime.websocketStreamRetries,
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
if (this.output.content.length > 0) {
|
|
1706
|
+
// Content already emitted to the caller — cannot safely continue on a new WS.
|
|
1707
|
+
// Reset and replay the full request over SSE.
|
|
1708
|
+
this.runtime.resetAccumulators();
|
|
1709
|
+
resetOutputState(this.output);
|
|
1710
|
+
this.firstTokenTime = undefined;
|
|
1711
|
+
recordCodexWebSocketFailure(websocketState, true);
|
|
1712
|
+
await this.#reopenSseStream(websocketState);
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// No content emitted yet — clear accumulator state from the failed attempt
|
|
1717
|
+
// (blockless native items can exist even with empty content) and reconnect
|
|
1718
|
+
// over websocket, bounded by the shared retry budget: an account-scoped
|
|
1719
|
+
// limit can reject every fresh connection, and an unbounded loop would
|
|
1720
|
+
// hammer the endpoint with zero backoff.
|
|
1721
|
+
this.runtime.resetAccumulators();
|
|
1722
|
+
this.firstTokenTime = undefined;
|
|
1723
|
+
if (this.runtime.websocketStreamRetries >= CODEX_WEBSOCKET_RETRY_BUDGET) {
|
|
1724
|
+
recordCodexWebSocketFailure(websocketState, true);
|
|
1725
|
+
await this.#reopenSseStream(websocketState);
|
|
1726
|
+
return true;
|
|
1727
|
+
}
|
|
1728
|
+
this.runtime.websocketStreamRetries += 1;
|
|
1729
|
+
await scheduler.wait(CODEX_WEBSOCKET_RETRY_DELAY_MS * Math.max(1, this.runtime.websocketStreamRetries), {
|
|
1730
|
+
signal: this.requestSetup.requestSignal,
|
|
1731
|
+
});
|
|
1732
|
+
await this.#reopenWebSocketStream(websocketState);
|
|
1733
|
+
return true;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
async #tryRecoverPreviousResponseNotFound(error: unknown): Promise<boolean> {
|
|
1737
|
+
const websocketState = this.requestContext.websocketState;
|
|
1738
|
+
if (
|
|
1739
|
+
!isCodexStalePreviousResponseError(error) ||
|
|
1740
|
+
!websocketState ||
|
|
1741
|
+
this.output.content.length > 0 ||
|
|
1742
|
+
this.options?.signal?.aborted ||
|
|
1743
|
+
this.runtime.providerRetryAttempt >= CODEX_MAX_RETRIES
|
|
1744
|
+
) {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
if (this.runtime.transport !== "websocket") {
|
|
1748
|
+
// SSE never sends previous_response_id; let other recovery handle it.
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
this.runtime.providerRetryAttempt += 1;
|
|
1753
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1754
|
+
websocketState.turnState = undefined;
|
|
1755
|
+
websocketState.modelsEtag = undefined;
|
|
1756
|
+
this.runtime.resetAccumulators();
|
|
1757
|
+
this.runtime.sawTerminalEvent = false;
|
|
1758
|
+
resetOutputState(this.output);
|
|
1759
|
+
this.firstTokenTime = undefined;
|
|
1760
|
+
|
|
1761
|
+
CODEX_DEBUG &&
|
|
1762
|
+
logger.debug("[codex] codex previous_response_id expired; retrying with full context", {
|
|
1763
|
+
retry: this.runtime.providerRetryAttempt,
|
|
1764
|
+
});
|
|
1765
|
+
await this.#reopenWebSocketStream(websocketState);
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
async #tryReplayWebsocketFailureOverSse(error: unknown): Promise<boolean> {
|
|
1770
|
+
const websocketState = this.requestContext.websocketState;
|
|
1771
|
+
const canReplay =
|
|
1772
|
+
this.runtime.transport === "websocket" &&
|
|
1773
|
+
websocketState &&
|
|
1774
|
+
isCodexWebSocketRetryableStreamError(error) &&
|
|
1775
|
+
this.runtime.canSafelyReplayWebsocketOverSse &&
|
|
1776
|
+
!this.runtime.sawTerminalEvent &&
|
|
1777
|
+
!this.options?.signal?.aborted;
|
|
1778
|
+
if (!canReplay) return false;
|
|
1779
|
+
|
|
1780
|
+
const state = websocketState;
|
|
1781
|
+
const streamError = error instanceof Error ? error : new Error(String(error));
|
|
1782
|
+
const replayingBufferedOutputOverSse = this.output.content.length > 0;
|
|
1783
|
+
const fatalWebSocketMessage = streamError.message.toLowerCase();
|
|
1784
|
+
const isFatal = CODEX_WEBSOCKET_FATAL_PATTERNS.some(pattern =>
|
|
1785
|
+
fatalWebSocketMessage.includes(pattern.toLowerCase()),
|
|
1786
|
+
);
|
|
1787
|
+
const activateFallback =
|
|
1788
|
+
replayingBufferedOutputOverSse ||
|
|
1789
|
+
isFatal ||
|
|
1790
|
+
this.runtime.websocketStreamRetries >= CODEX_WEBSOCKET_RETRY_BUDGET;
|
|
1791
|
+
recordCodexWebSocketFailure(state, activateFallback);
|
|
1792
|
+
CODEX_DEBUG &&
|
|
1793
|
+
logger.debug("[codex] codex websocket stream fallback", {
|
|
1794
|
+
error: streamError.message,
|
|
1795
|
+
retry: this.runtime.websocketStreamRetries,
|
|
1796
|
+
retryBudget: CODEX_WEBSOCKET_RETRY_BUDGET,
|
|
1797
|
+
activated: activateFallback,
|
|
1798
|
+
fatal: isFatal,
|
|
1799
|
+
replayedBufferedOutput: replayingBufferedOutputOverSse,
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
if (!activateFallback) {
|
|
1803
|
+
this.runtime.websocketStreamRetries += 1;
|
|
1804
|
+
// Full re-send on a fresh socket: clear accumulator state from the failed
|
|
1805
|
+
// attempt. Content is empty here, but blockless native items (e.g.
|
|
1806
|
+
// web_search_call) may already have accumulated.
|
|
1807
|
+
this.runtime.resetAccumulators();
|
|
1808
|
+
this.firstTokenTime = undefined;
|
|
1809
|
+
await scheduler.wait(CODEX_WEBSOCKET_RETRY_DELAY_MS * Math.max(1, this.runtime.websocketStreamRetries), {
|
|
1810
|
+
signal: this.requestSetup.requestSignal,
|
|
1811
|
+
});
|
|
1812
|
+
await this.#reopenWebSocketStream(state);
|
|
1813
|
+
return true;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
this.runtime.resetAccumulators();
|
|
1817
|
+
resetOutputState(this.output);
|
|
1818
|
+
this.firstTokenTime = undefined;
|
|
1819
|
+
|
|
1820
|
+
await this.#reopenSseStream(state);
|
|
1821
|
+
return true;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async #tryRetryProviderError(error: unknown): Promise<boolean> {
|
|
1825
|
+
if (
|
|
1826
|
+
!(error instanceof CodexProviderStreamError && error.retryable) ||
|
|
1827
|
+
this.output.content.length > 0 ||
|
|
1828
|
+
this.runtime.providerRetryAttempt >= CODEX_MAX_RETRIES ||
|
|
1829
|
+
this.options?.signal?.aborted
|
|
1830
|
+
) {
|
|
1831
|
+
return false;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
this.runtime.providerRetryAttempt += 1;
|
|
1835
|
+
const websocketState = this.requestContext.websocketState;
|
|
1836
|
+
if (websocketState) {
|
|
1837
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1838
|
+
websocketState.turnState = undefined;
|
|
1839
|
+
websocketState.modelsEtag = undefined;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
CODEX_DEBUG &&
|
|
1843
|
+
logger.debug("[codex] retrying codex provider stream error", {
|
|
1844
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1845
|
+
retry: this.runtime.providerRetryAttempt,
|
|
1846
|
+
retryBudget: CODEX_MAX_RETRIES,
|
|
1847
|
+
transport: this.runtime.transport,
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
this.runtime.resetAccumulators();
|
|
1851
|
+
this.runtime.sawTerminalEvent = false;
|
|
1852
|
+
resetOutputState(this.output);
|
|
1853
|
+
this.firstTokenTime = undefined;
|
|
1854
|
+
await scheduler.wait(CODEX_RETRY_DELAY_MS * this.runtime.providerRetryAttempt, {
|
|
1855
|
+
signal: this.requestSetup.requestSignal,
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
if (this.runtime.transport === "websocket" && websocketState) {
|
|
1859
|
+
await this.#reopenWebSocketStream(websocketState);
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
await this.#reopenSseStream(websocketState);
|
|
1864
|
+
return true;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
async #reopenWebSocketStream(state: CodexWebSocketSessionState): Promise<void> {
|
|
1868
|
+
try {
|
|
1869
|
+
const next = await openCodexWebSocketTransport(
|
|
1870
|
+
this.model,
|
|
1871
|
+
this.options,
|
|
1872
|
+
this.requestContext,
|
|
1873
|
+
this.requestSetup,
|
|
1874
|
+
state,
|
|
1875
|
+
this.runtime.websocketStreamRetries,
|
|
1876
|
+
this.options ? event => this.options?.onSseEvent?.(event, this.model) : undefined,
|
|
1877
|
+
);
|
|
1878
|
+
this.runtime.eventStream = next.eventStream;
|
|
1879
|
+
this.runtime.requestBodyForState = next.requestBodyForState;
|
|
1880
|
+
this.runtime.transport = next.transport;
|
|
1881
|
+
state.lastTransport = next.transport;
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
if (!(error instanceof CodexWebSocketTransportError)) throw error;
|
|
1884
|
+
// Reopen failed at the websocket layer (handshake refused, connect timeout, etc.).
|
|
1885
|
+
// Activate fallback so subsequent turns use SSE, and replay this turn over SSE
|
|
1886
|
+
// instead of surfacing a raw transport error to the caller.
|
|
1887
|
+
recordCodexWebSocketFailure(state, true);
|
|
1888
|
+
CODEX_DEBUG &&
|
|
1889
|
+
logger.debug("[codex] codex websocket reopen failed, falling back to SSE", {
|
|
1890
|
+
error: error.message,
|
|
1891
|
+
retry: this.runtime.websocketStreamRetries,
|
|
1892
|
+
});
|
|
1893
|
+
await this.#reopenSseStream(state);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
async #reopenSseStream(state: CodexWebSocketSessionState | undefined): Promise<void> {
|
|
1898
|
+
const next = await openCodexSseTransport(this.model, this.requestContext, this.requestSetup, this.options, state);
|
|
1899
|
+
this.runtime.eventStream = next.eventStream;
|
|
1900
|
+
this.runtime.requestBodyForState = next.requestBodyForState;
|
|
1901
|
+
this.runtime.transport = next.transport;
|
|
1902
|
+
if (state) {
|
|
1903
|
+
state.lastTransport = next.transport;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
finalize(completion: CodexStreamCompletion): AssistantMessage {
|
|
1908
|
+
const { output } = this;
|
|
1909
|
+
if (this.options?.signal?.aborted) {
|
|
1910
|
+
throw new AIError.AbortError();
|
|
1911
|
+
}
|
|
1912
|
+
if (!this.runtime.sawTerminalEvent) {
|
|
1913
|
+
if (this.requestContext.websocketState) {
|
|
1914
|
+
resetCodexWebSocketAppendState(this.requestContext.websocketState);
|
|
1915
|
+
this.requestContext.websocketState.turnState = undefined;
|
|
1916
|
+
this.requestContext.websocketState.modelsEtag = undefined;
|
|
1917
|
+
}
|
|
1918
|
+
CODEX_DEBUG &&
|
|
1919
|
+
logger.debug("[codex] codex stream ended unexpectedly", {
|
|
1920
|
+
transport: this.runtime.transport,
|
|
1921
|
+
terminalEventSeen: this.runtime.sawTerminalEvent,
|
|
1922
|
+
unexpectedStreamEnd: true,
|
|
1923
|
+
sentTurnStateHeader: Boolean(this.requestContext.websocketState?.turnState),
|
|
1924
|
+
sentModelsEtagHeader: Boolean(this.requestContext.websocketState?.modelsEtag),
|
|
1925
|
+
});
|
|
1926
|
+
throw new CodexProviderStreamError("Codex stream ended before terminal completion event", false);
|
|
1927
|
+
}
|
|
1928
|
+
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
1929
|
+
throw new CodexProviderStreamError("Codex response failed", false);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
output.providerPayload = createOpenAIResponsesHistoryPayload(this.model.provider, this.runtime.nativeOutputItems);
|
|
1933
|
+
output.duration = performance.now() - this.startTime;
|
|
1934
|
+
if (completion.firstTokenTime) {
|
|
1935
|
+
output.ttft = completion.firstTokenTime - this.startTime;
|
|
1936
|
+
}
|
|
1937
|
+
return output;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = (
|
|
1942
|
+
model: Model<"openai-codex-responses">,
|
|
1943
|
+
context: Context,
|
|
1944
|
+
options?: OpenAICodexResponsesOptions,
|
|
1945
|
+
): AssistantMessageEventStream => {
|
|
1946
|
+
const stream = new AssistantMessageEventStream();
|
|
1947
|
+
|
|
1948
|
+
(async () => {
|
|
1949
|
+
const startTime = performance.now();
|
|
1950
|
+
const output: AssistantMessage = {
|
|
1951
|
+
role: "assistant",
|
|
1952
|
+
content: [],
|
|
1953
|
+
api: "openai-codex-responses" as Api,
|
|
1954
|
+
provider: model.provider,
|
|
1955
|
+
model: model.id,
|
|
1956
|
+
usage: {
|
|
1957
|
+
input: 0,
|
|
1958
|
+
output: 0,
|
|
1959
|
+
cacheRead: 0,
|
|
1960
|
+
cacheWrite: 0,
|
|
1961
|
+
totalTokens: 0,
|
|
1962
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
1963
|
+
},
|
|
1964
|
+
stopReason: "stop",
|
|
1965
|
+
timestamp: Date.now(),
|
|
1966
|
+
};
|
|
1967
|
+
const requestSetup = createRequestSetup(options);
|
|
1968
|
+
let processingContext: CodexStreamProcessor | undefined;
|
|
1969
|
+
let requestContext: CodexRequestContext | undefined;
|
|
1970
|
+
|
|
1971
|
+
try {
|
|
1972
|
+
requestContext = await buildCodexRequestContext(model, context, options, output);
|
|
1973
|
+
const initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
|
|
1974
|
+
const runtime = new CodexStreamRuntime({
|
|
1975
|
+
...initialTransport,
|
|
1976
|
+
websocketState: requestContext.websocketState,
|
|
1977
|
+
});
|
|
1978
|
+
if (requestContext.websocketState) {
|
|
1979
|
+
requestContext.websocketState.lastTransport = initialTransport.transport;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
processingContext = new CodexStreamProcessor({
|
|
1983
|
+
runtime,
|
|
1984
|
+
model,
|
|
1985
|
+
output,
|
|
1986
|
+
stream,
|
|
1987
|
+
options,
|
|
1988
|
+
requestSetup,
|
|
1989
|
+
requestContext,
|
|
1990
|
+
startTime,
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
const completion = await processingContext.process();
|
|
1994
|
+
processingContext.firstTokenTime = completion.firstTokenTime;
|
|
1995
|
+
const message = processingContext.finalize(completion);
|
|
1996
|
+
stream.push({ type: "done", reason: message.stopReason as "stop" | "length" | "toolUse", message });
|
|
1997
|
+
stream.end();
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
const failureContext =
|
|
2000
|
+
processingContext ??
|
|
2001
|
+
({
|
|
2002
|
+
model,
|
|
2003
|
+
output,
|
|
2004
|
+
options,
|
|
2005
|
+
requestContext: requestContext ?? {
|
|
2006
|
+
apiKey: "",
|
|
2007
|
+
accountId: "",
|
|
2008
|
+
baseUrl: model.baseUrl || CODEX_BASE_URL,
|
|
2009
|
+
url: "",
|
|
2010
|
+
requestHeaders: {},
|
|
2011
|
+
responsesLite: options?.responsesLite === true,
|
|
2012
|
+
transformedBody: { model: model.id },
|
|
2013
|
+
rawRequestDump: {
|
|
2014
|
+
provider: model.provider,
|
|
2015
|
+
api: output.api,
|
|
2016
|
+
model: model.id,
|
|
2017
|
+
method: "POST",
|
|
2018
|
+
url: "",
|
|
2019
|
+
body: { model: model.id },
|
|
2020
|
+
},
|
|
2021
|
+
},
|
|
2022
|
+
startTime,
|
|
2023
|
+
} satisfies CodexStreamFailureContext);
|
|
2024
|
+
try {
|
|
2025
|
+
const failure = await handleCodexStreamFailure(failureContext, error);
|
|
2026
|
+
stream.push({ type: "error", reason: failure.stopReason as "error" | "aborted", error: failure });
|
|
2027
|
+
} catch (failureError) {
|
|
2028
|
+
// Last resort — the failure handler itself threw (exotic error object or
|
|
2029
|
+
// request-dump formatting). Never leave the stream un-ended.
|
|
2030
|
+
logger.error("Codex stream failure handler threw", {
|
|
2031
|
+
error: failureError instanceof Error ? failureError.message : String(failureError),
|
|
2032
|
+
});
|
|
2033
|
+
output.stopReason = "error";
|
|
2034
|
+
output.errorMessage ??= error instanceof Error ? error.message : String(error);
|
|
2035
|
+
stream.push({ type: "error", reason: "error", error: output });
|
|
2036
|
+
}
|
|
2037
|
+
stream.end();
|
|
2038
|
+
}
|
|
2039
|
+
})();
|
|
2040
|
+
|
|
2041
|
+
return stream;
|
|
2042
|
+
};
|
|
2043
|
+
|
|
2044
|
+
export async function prewarmOpenAICodexResponses(
|
|
2045
|
+
model: Model<"openai-codex-responses">,
|
|
2046
|
+
options?: Pick<
|
|
2047
|
+
OpenAICodexResponsesOptions,
|
|
2048
|
+
"apiKey" | "headers" | "sessionId" | "signal" | "preferWebsockets" | "providerSessionState" | "responsesLite"
|
|
2049
|
+
>,
|
|
2050
|
+
): Promise<void> {
|
|
2051
|
+
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
|
2052
|
+
if (!apiKey) return;
|
|
2053
|
+
const accountId = getAccountId(apiKey);
|
|
2054
|
+
const baseUrl = model.baseUrl || CODEX_BASE_URL;
|
|
2055
|
+
const url = resolveCodexResponsesUrl(baseUrl);
|
|
2056
|
+
const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
|
2057
|
+
const promptCacheKey = transportSessionId;
|
|
2058
|
+
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
2059
|
+
const responsesLite = options?.responsesLite === true;
|
|
2060
|
+
const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite);
|
|
2061
|
+
const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined;
|
|
2062
|
+
if (publicSessionKey && sessionKey) {
|
|
2063
|
+
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
2064
|
+
}
|
|
2065
|
+
if (!sessionKey || !providerSessionState) return;
|
|
2066
|
+
const state = getCodexWebSocketSessionState(sessionKey, providerSessionState);
|
|
2067
|
+
if (!shouldUseCodexWebSocket(model, state, options?.preferWebsockets)) return;
|
|
2068
|
+
const headers = logger.time(
|
|
2069
|
+
"prewarmCodex:createHeaders",
|
|
2070
|
+
createCodexHeaders,
|
|
2071
|
+
{ ...(model.headers ?? {}), ...(options?.headers ?? {}) },
|
|
2072
|
+
accountId,
|
|
2073
|
+
apiKey,
|
|
2074
|
+
promptCacheKey,
|
|
2075
|
+
"websocket",
|
|
2076
|
+
state,
|
|
2077
|
+
responsesLite,
|
|
2078
|
+
);
|
|
2079
|
+
await logger.time(
|
|
2080
|
+
"prewarmCodex:establishWs",
|
|
2081
|
+
getOrCreateCodexWebSocketConnection,
|
|
2082
|
+
state,
|
|
2083
|
+
toWebSocketUrl(url),
|
|
2084
|
+
headers,
|
|
2085
|
+
options?.signal,
|
|
2086
|
+
);
|
|
2087
|
+
state.prewarmed = true;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function getCodexWebSocketSessionKey(
|
|
2091
|
+
normalizedSessionId: string | undefined,
|
|
2092
|
+
model: Model<"openai-codex-responses">,
|
|
2093
|
+
accountId: string,
|
|
2094
|
+
baseUrl: string,
|
|
2095
|
+
responsesLite: boolean,
|
|
2096
|
+
): string | undefined {
|
|
2097
|
+
if (!normalizedSessionId) return undefined;
|
|
2098
|
+
// Responses Lite is connection-scoped on the WebSocket upgrade, so lite and
|
|
2099
|
+
// non-lite turns must never share a pooled socket or append state.
|
|
2100
|
+
const liteSuffix = responsesLite ? ":lite" : "";
|
|
2101
|
+
return `${accountId}:${baseUrl}:${model.id}:${normalizedSessionId}${liteSuffix}`;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function getCodexWebSocketSessionState(
|
|
2105
|
+
sessionKey: string,
|
|
2106
|
+
providerSessionState: CodexProviderSessionState,
|
|
2107
|
+
): CodexWebSocketSessionState {
|
|
2108
|
+
const existing = providerSessionState.webSocketSessions.get(sessionKey);
|
|
2109
|
+
if (existing) return existing;
|
|
2110
|
+
const created: CodexWebSocketSessionState = {
|
|
2111
|
+
disableWebsocket: false,
|
|
2112
|
+
canAppend: false,
|
|
2113
|
+
fallbackCount: 0,
|
|
2114
|
+
prewarmed: false,
|
|
2115
|
+
stats: {
|
|
2116
|
+
fullContextRequests: 0,
|
|
2117
|
+
deltaRequests: 0,
|
|
2118
|
+
lastInputItems: 0,
|
|
2119
|
+
},
|
|
2120
|
+
};
|
|
2121
|
+
providerSessionState.webSocketSessions.set(sessionKey, created);
|
|
2122
|
+
return created;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
function resetCodexWebSocketAppendState(state: CodexWebSocketSessionState): void {
|
|
2126
|
+
state.canAppend = false;
|
|
2127
|
+
state.lastRequest = undefined;
|
|
2128
|
+
state.lastResponseId = undefined;
|
|
2129
|
+
state.lastResponseItems = undefined;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function recordCodexWebSocketFailure(state: CodexWebSocketSessionState, activateFallback: boolean): void {
|
|
2133
|
+
resetCodexWebSocketAppendState(state);
|
|
2134
|
+
// Never tear down a CONNECTING socket: it belongs to a concurrent caller's
|
|
2135
|
+
// in-flight handshake (prewarm/request race); closing it would reject that
|
|
2136
|
+
// caller with a fatal "websocket closed before open" and disable websockets
|
|
2137
|
+
// for the whole session.
|
|
2138
|
+
if (state.connection && !state.connection.isConnecting()) {
|
|
2139
|
+
state.connection.close("fallback");
|
|
2140
|
+
state.connection = undefined;
|
|
2141
|
+
}
|
|
2142
|
+
state.lastFallbackAt = Date.now();
|
|
2143
|
+
if (activateFallback && !state.disableWebsocket) {
|
|
2144
|
+
state.disableWebsocket = true;
|
|
2145
|
+
state.fallbackCount += 1;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function getCodexWebSocketEnvValue(): boolean | undefined {
|
|
2150
|
+
const envVal = $env.PI_CODEX_WEBSOCKET;
|
|
2151
|
+
if (envVal !== undefined) {
|
|
2152
|
+
return $flag("PI_CODEX_WEBSOCKET");
|
|
2153
|
+
}
|
|
2154
|
+
return undefined;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function shouldUseCodexWebSocket(
|
|
2158
|
+
model: Model<"openai-codex-responses">,
|
|
2159
|
+
state: CodexWebSocketSessionState | undefined,
|
|
2160
|
+
preferWebsockets?: boolean,
|
|
2161
|
+
): boolean {
|
|
2162
|
+
// Explicitly disabled by the model or session state.
|
|
2163
|
+
if (model.preferWebsockets === false) return false;
|
|
2164
|
+
// Explicitly disabled by the session state.
|
|
2165
|
+
if (!state || state.disableWebsocket) return false;
|
|
2166
|
+
// Env val > Preference
|
|
2167
|
+
const envVal = getCodexWebSocketEnvValue();
|
|
2168
|
+
if (envVal !== undefined) return envVal;
|
|
2169
|
+
// Negative preference overrides model preference; otherwise use the model's preference.
|
|
2170
|
+
if (preferWebsockets === false) return false;
|
|
2171
|
+
return true;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
export interface OpenAICodexTransportDetails {
|
|
2175
|
+
websocketPreferred: boolean;
|
|
2176
|
+
lastTransport?: CodexTransport;
|
|
2177
|
+
websocketDisabled: boolean;
|
|
2178
|
+
websocketConnected: boolean;
|
|
2179
|
+
fallbackCount: number;
|
|
2180
|
+
canAppend: boolean;
|
|
2181
|
+
prewarmed: boolean;
|
|
2182
|
+
hasSessionState: boolean;
|
|
2183
|
+
lastFallbackAt?: number;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function getCodexWebSocketStateForPublicSession(
|
|
2187
|
+
model: Model<"openai-codex-responses">,
|
|
2188
|
+
options:
|
|
2189
|
+
| {
|
|
2190
|
+
sessionId?: string;
|
|
2191
|
+
baseUrl?: string;
|
|
2192
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
2193
|
+
}
|
|
2194
|
+
| undefined,
|
|
2195
|
+
): CodexWebSocketSessionState | undefined {
|
|
2196
|
+
const baseUrl = options?.baseUrl || model.baseUrl || CODEX_BASE_URL;
|
|
2197
|
+
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
2198
|
+
const normalizedSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
|
2199
|
+
const publicSessionKey = normalizedSessionId ? `${baseUrl}:${model.id}:${normalizedSessionId}` : undefined;
|
|
2200
|
+
const privateSessionKey = publicSessionKey
|
|
2201
|
+
? providerSessionState?.webSocketPublicToPrivate.get(publicSessionKey)
|
|
2202
|
+
: undefined;
|
|
2203
|
+
return privateSessionKey ? providerSessionState?.webSocketSessions.get(privateSessionKey) : undefined;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
export function getOpenAICodexWebSocketDebugStats(
|
|
2207
|
+
model: Model<"openai-codex-responses">,
|
|
2208
|
+
options?: {
|
|
2209
|
+
sessionId?: string;
|
|
2210
|
+
baseUrl?: string;
|
|
2211
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
2212
|
+
},
|
|
2213
|
+
): OpenAICodexWebSocketDebugStats | undefined {
|
|
2214
|
+
const stats = getCodexWebSocketStateForPublicSession(model, options)?.stats;
|
|
2215
|
+
return stats ? { ...stats } : undefined;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
export function getOpenAICodexTransportDetails(
|
|
2219
|
+
model: Model<"openai-codex-responses">,
|
|
2220
|
+
options?: {
|
|
2221
|
+
sessionId?: string;
|
|
2222
|
+
baseUrl?: string;
|
|
2223
|
+
preferWebsockets?: boolean;
|
|
2224
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
2225
|
+
},
|
|
2226
|
+
): OpenAICodexTransportDetails {
|
|
2227
|
+
const envVal = getCodexWebSocketEnvValue();
|
|
2228
|
+
const websocketPreferred =
|
|
2229
|
+
envVal !== undefined
|
|
2230
|
+
? envVal
|
|
2231
|
+
: options?.preferWebsockets === false
|
|
2232
|
+
? false
|
|
2233
|
+
: options?.preferWebsockets === true || model.preferWebsockets === true;
|
|
2234
|
+
const state = getCodexWebSocketStateForPublicSession(model, options);
|
|
2235
|
+
|
|
2236
|
+
return {
|
|
2237
|
+
websocketPreferred,
|
|
2238
|
+
lastTransport: state?.lastTransport,
|
|
2239
|
+
websocketDisabled: state?.disableWebsocket ?? false,
|
|
2240
|
+
websocketConnected: state?.connection?.isOpen() ?? false,
|
|
2241
|
+
fallbackCount: state?.fallbackCount ?? 0,
|
|
2242
|
+
canAppend: state?.canAppend ?? false,
|
|
2243
|
+
prewarmed: state?.prewarmed ?? false,
|
|
2244
|
+
hasSessionState: state !== undefined,
|
|
2245
|
+
lastFallbackAt: state?.lastFallbackAt,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function stripInputItemIds(items: Array<Record<string, unknown>>): InputItem[] {
|
|
2250
|
+
return items.map(item => {
|
|
2251
|
+
if (item.id == null) return item as InputItem;
|
|
2252
|
+
const { id: _id, ...rest } = item;
|
|
2253
|
+
return rest as InputItem;
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
function recordCodexWebSocketRequestStats(
|
|
2258
|
+
state: CodexWebSocketSessionState | undefined,
|
|
2259
|
+
request: Record<string, unknown>,
|
|
2260
|
+
): void {
|
|
2261
|
+
if (!state) return;
|
|
2262
|
+
const input = request.input;
|
|
2263
|
+
state.stats.lastInputItems = Array.isArray(input) ? input.length : 0;
|
|
2264
|
+
if (typeof request.previous_response_id === "string" && request.previous_response_id.length > 0) {
|
|
2265
|
+
state.stats.deltaRequests += 1;
|
|
2266
|
+
state.stats.lastDeltaInputItems = state.stats.lastInputItems;
|
|
2267
|
+
state.stats.lastPreviousResponseId = request.previous_response_id;
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
state.stats.fullContextRequests += 1;
|
|
2271
|
+
state.stats.lastDeltaInputItems = undefined;
|
|
2272
|
+
state.stats.lastPreviousResponseId = undefined;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/**
|
|
2276
|
+
* Shape the next websocket turn's request body: when the session's append
|
|
2277
|
+
* baseline is intact (same options, strict history prefix), chain via
|
|
2278
|
+
* `previous_response_id` + delta-only `input`; otherwise break the chain and
|
|
2279
|
+
* replay the full transcript. SSE requests never chain — the HTTP endpoint's
|
|
2280
|
+
* request schema has no `previous_response_id` (codex-rs carries it only on
|
|
2281
|
+
* websocket `response.create` frames) and strict gateway validators 400 it
|
|
2282
|
+
* with `{"detail":"Unsupported parameter: previous_response_id"}`.
|
|
2283
|
+
*/
|
|
2284
|
+
function buildCodexChainedRequestBody(
|
|
2285
|
+
requestBody: RequestBody,
|
|
2286
|
+
state: CodexWebSocketSessionState | undefined,
|
|
2287
|
+
): RequestBody {
|
|
2288
|
+
const chainable = state?.canAppend === true;
|
|
2289
|
+
const appendInput = chainable
|
|
2290
|
+
? buildResponsesDeltaInput(state.lastRequest, state.lastResponseItems, requestBody)
|
|
2291
|
+
: null;
|
|
2292
|
+
if (appendInput && appendInput.length > 0 && state?.lastResponseId) {
|
|
2293
|
+
const body: RequestBody = { ...requestBody, previous_response_id: state.lastResponseId, input: appendInput };
|
|
2294
|
+
recordCodexWebSocketRequestStats(state, body);
|
|
2295
|
+
return body;
|
|
2296
|
+
}
|
|
2297
|
+
if (chainable && state) {
|
|
2298
|
+
// Chaining was eligible but the prefix/options check failed: history
|
|
2299
|
+
// mutated or options changed — break the chain.
|
|
2300
|
+
CODEX_DEBUG &&
|
|
2301
|
+
logger.debug("[codex] codex append reset", {
|
|
2302
|
+
hadTurnStateHeader: Boolean(state.turnState),
|
|
2303
|
+
hadModelsEtagHeader: Boolean(state.modelsEtag),
|
|
2304
|
+
});
|
|
2305
|
+
resetCodexWebSocketAppendState(state);
|
|
2306
|
+
state.turnState = undefined;
|
|
2307
|
+
state.modelsEtag = undefined;
|
|
2308
|
+
}
|
|
2309
|
+
recordCodexWebSocketRequestStats(state, requestBody);
|
|
2310
|
+
return requestBody;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function toWebSocketUrl(url: string): string {
|
|
2314
|
+
const parsed = new URL(url);
|
|
2315
|
+
if (parsed.protocol === "https:") {
|
|
2316
|
+
parsed.protocol = "wss:";
|
|
2317
|
+
} else if (parsed.protocol === "http:") {
|
|
2318
|
+
parsed.protocol = "ws:";
|
|
2319
|
+
}
|
|
2320
|
+
return parsed.toString();
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
2324
|
+
const result: Record<string, string> = {};
|
|
2325
|
+
for (const [key, value] of headers.entries()) {
|
|
2326
|
+
result[key] = value;
|
|
2327
|
+
}
|
|
2328
|
+
return result;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
interface CodexWebSocketRequestTimeouts {
|
|
2332
|
+
idleTimeoutMs?: number;
|
|
2333
|
+
firstEventTimeoutMs?: number;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
interface CodexWebSocketConnectionOptions {
|
|
2337
|
+
onHandshakeHeaders?: (headers: Headers) => void;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
class CodexWebSocketConnection {
|
|
2341
|
+
#url: string;
|
|
2342
|
+
#headers: Record<string, string>;
|
|
2343
|
+
#onHandshakeHeaders?: (headers: Headers) => void;
|
|
2344
|
+
#socket: Bun.WebSocket | null = null;
|
|
2345
|
+
#queue: Array<Record<string, unknown> | Error | null> = [];
|
|
2346
|
+
#waiters: Array<() => void> = [];
|
|
2347
|
+
#connectPromise?: Promise<void>;
|
|
2348
|
+
#activeRequest = false;
|
|
2349
|
+
#streamObserver?: (event: RawSseEvent) => void;
|
|
2350
|
+
#heartbeatInterval: NodeJS.Timeout | undefined;
|
|
2351
|
+
#removePongListener?: () => void;
|
|
2352
|
+
#handshakeHeaders?: Headers;
|
|
2353
|
+
#debugResponseLog?: RequestDebugResponseLog;
|
|
2354
|
+
/**
|
|
2355
|
+
* Wall-clock of the most recent inbound activity on this socket — any
|
|
2356
|
+
* decoded message, any pong, or the moment the handshake completed. Used
|
|
2357
|
+
* by {@link isHealthyForReuse} so we don't write a continuation frame into
|
|
2358
|
+
* a TCP-open-but-server-evicted socket whose `readyState` still says OPEN.
|
|
2359
|
+
*/
|
|
2360
|
+
#lastInboundAt = 0;
|
|
2361
|
+
/** Wall-clock of the last heartbeat ping we issued; 0 if none yet. */
|
|
2362
|
+
#lastPingAt = 0;
|
|
2363
|
+
/**
|
|
2364
|
+
* Most recent `response.id` accepted on this socket, retained across
|
|
2365
|
+
* requests. Lets the next request drop a trailing/duplicate frame from the
|
|
2366
|
+
* previous (cleanly-completed) response that outlived the queue drain.
|
|
2367
|
+
*/
|
|
2368
|
+
#lastSeenResponseId?: string;
|
|
2369
|
+
|
|
2370
|
+
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
|
|
2371
|
+
this.#url = url;
|
|
2372
|
+
this.#headers = headers;
|
|
2373
|
+
this.#onHandshakeHeaders = options.onHandshakeHeaders;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
isOpen(): boolean {
|
|
2377
|
+
return this.#socket?.readyState === WebSocket.OPEN;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
/** True while a handshake (possibly started by another caller) is still in flight. */
|
|
2381
|
+
isConnecting(): boolean {
|
|
2382
|
+
return this.#connectPromise !== undefined;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* Stricter variant of {@link isOpen} for the connection-pool reuse gate.
|
|
2387
|
+
* Refuses sockets that have been silent past {@link CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS}.
|
|
2388
|
+
*
|
|
2389
|
+
* Bun's `WebSocket` does not always surface server-side eviction (no
|
|
2390
|
+
* `onclose`, no `onerror`), so a socket can sit in readyState OPEN long
|
|
2391
|
+
* after the upstream has dropped it. Reusing such a socket sends the next
|
|
2392
|
+
* `response.create` into a half-open write buffer and parks the reader
|
|
2393
|
+
* until the first-event / idle timeout fires (issue #1450). Forcing a
|
|
2394
|
+
* reconnect on any suspect socket trades a sub-second handshake for a
|
|
2395
|
+
* 60–300 s stall.
|
|
2396
|
+
*/
|
|
2397
|
+
isHealthyForReuse(): boolean {
|
|
2398
|
+
if (!this.isOpen()) return false;
|
|
2399
|
+
const maxIdleMs = CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS;
|
|
2400
|
+
if (maxIdleMs <= 0) return true;
|
|
2401
|
+
// Initial connect sets #lastInboundAt; any later message or pong refreshes
|
|
2402
|
+
// it. A zero value means the field was never initialized, which itself is
|
|
2403
|
+
// a desync — treat as unhealthy.
|
|
2404
|
+
if (this.#lastInboundAt === 0) return false;
|
|
2405
|
+
return Date.now() - this.#lastInboundAt <= maxIdleMs;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
matchesAuth(headers: Record<string, string>): boolean {
|
|
2409
|
+
return this.#headers.authorization === headers.authorization;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
close(reason = "done"): void {
|
|
2413
|
+
if (
|
|
2414
|
+
this.#socket &&
|
|
2415
|
+
(this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING)
|
|
2416
|
+
) {
|
|
2417
|
+
this.#socket.close(1000, reason);
|
|
2418
|
+
}
|
|
2419
|
+
this.#socket = null;
|
|
2420
|
+
this.#stopHeartbeat();
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
async connect(signal?: AbortSignal): Promise<void> {
|
|
2424
|
+
if (this.isOpen()) return;
|
|
2425
|
+
if (this.#connectPromise) {
|
|
2426
|
+
logger.time("codexWs:awaitSharedHandshake");
|
|
2427
|
+
await this.#connectPromise;
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
2431
|
+
this.#connectPromise = promise;
|
|
2432
|
+
const socket = new (WebSocket as unknown as new (url: string, opts: Bun.WebSocketOptions) => Bun.WebSocket)(
|
|
2433
|
+
this.#url,
|
|
2434
|
+
{ headers: this.#headers },
|
|
2435
|
+
);
|
|
2436
|
+
socket.binaryType = "nodebuffer";
|
|
2437
|
+
this.#socket = socket;
|
|
2438
|
+
let settled = false;
|
|
2439
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
2440
|
+
const clearPending = () => {
|
|
2441
|
+
if (timeout !== undefined) {
|
|
2442
|
+
clearTimeout(timeout);
|
|
2443
|
+
timeout = undefined;
|
|
2444
|
+
}
|
|
2445
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
2446
|
+
};
|
|
2447
|
+
const onAbort = () => {
|
|
2448
|
+
socket.close(1000, "aborted");
|
|
2449
|
+
if (!settled) {
|
|
2450
|
+
settled = true;
|
|
2451
|
+
clearPending();
|
|
2452
|
+
reject(new CodexWebSocketTransportError(`request was aborted`));
|
|
2453
|
+
}
|
|
2454
|
+
};
|
|
2455
|
+
if (signal) {
|
|
2456
|
+
if (signal.aborted) {
|
|
2457
|
+
onAbort();
|
|
2458
|
+
} else {
|
|
2459
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
if (!settled) {
|
|
2463
|
+
timeout = setTimeout(() => {
|
|
2464
|
+
socket.close(1000, "connect-timeout");
|
|
2465
|
+
if (!settled) {
|
|
2466
|
+
settled = true;
|
|
2467
|
+
clearPending();
|
|
2468
|
+
reject(new CodexWebSocketTransportError(`connection timeout`));
|
|
2469
|
+
}
|
|
2470
|
+
}, CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
socket.onopen = event => {
|
|
2474
|
+
if (!settled) {
|
|
2475
|
+
settled = true;
|
|
2476
|
+
clearPending();
|
|
2477
|
+
this.#lastInboundAt = Date.now();
|
|
2478
|
+
this.#captureHandshakeHeaders(socket, event);
|
|
2479
|
+
this.#startHeartbeat(socket);
|
|
2480
|
+
resolve();
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
2483
|
+
socket.onerror = event => {
|
|
2484
|
+
const eventRecord = event as unknown as Record<string, unknown>;
|
|
2485
|
+
const detail =
|
|
2486
|
+
(typeof eventRecord.message === "string" && eventRecord.message) ||
|
|
2487
|
+
(eventRecord.error instanceof Error && eventRecord.error.message) ||
|
|
2488
|
+
String(event.type);
|
|
2489
|
+
const error = new CodexWebSocketTransportError(`websocket error: ${detail}`);
|
|
2490
|
+
if (!settled) {
|
|
2491
|
+
settled = true;
|
|
2492
|
+
clearPending();
|
|
2493
|
+
reject(error);
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
this.#push(error);
|
|
2497
|
+
};
|
|
2498
|
+
socket.onclose = event => {
|
|
2499
|
+
this.#socket = null;
|
|
2500
|
+
this.#stopHeartbeat();
|
|
2501
|
+
if (!settled) {
|
|
2502
|
+
settled = true;
|
|
2503
|
+
clearPending();
|
|
2504
|
+
reject(new CodexWebSocketTransportError(`websocket closed before open (${event.code})`));
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
this.#push(new CodexWebSocketTransportError(`websocket closed (${event.code})`));
|
|
2508
|
+
this.#push(null);
|
|
2509
|
+
};
|
|
2510
|
+
socket.onmessage = event => {
|
|
2511
|
+
// Stamp inbound activity before parsing so even malformed frames refresh
|
|
2512
|
+
// the liveness clock — what matters for reuse health is that the upstream
|
|
2513
|
+
// is still talking to us, not that every frame is well-formed.
|
|
2514
|
+
this.#lastInboundAt = Date.now();
|
|
2515
|
+
this.#writeDebugWebSocketFrame(event.data);
|
|
2516
|
+
try {
|
|
2517
|
+
const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf-8");
|
|
2518
|
+
if (!text) return;
|
|
2519
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
2520
|
+
if (parsed.type === "error" && typeof parsed.error === "object" && parsed.error) {
|
|
2521
|
+
const inner = parsed.error as Record<string, unknown>;
|
|
2522
|
+
if (typeof parsed.code !== "string" && typeof inner.code === "string") {
|
|
2523
|
+
parsed.code = inner.code;
|
|
2524
|
+
}
|
|
2525
|
+
if (typeof parsed.message !== "string" && typeof inner.message === "string") {
|
|
2526
|
+
parsed.message = inner.message;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
notifyCodexWebSocketInbound(this.#streamObserver, parsed, text);
|
|
2530
|
+
this.#push(parsed);
|
|
2531
|
+
} catch (error) {
|
|
2532
|
+
notifyCodexWebSocketMalformed(this.#streamObserver, event.data, error);
|
|
2533
|
+
this.#push(new CodexWebSocketTransportError(`${String(error)}`));
|
|
2534
|
+
}
|
|
2535
|
+
};
|
|
2536
|
+
|
|
2537
|
+
logger.time("codexWs:awaitTcpHandshake");
|
|
2538
|
+
try {
|
|
2539
|
+
await promise;
|
|
2540
|
+
} finally {
|
|
2541
|
+
this.#connectPromise = undefined;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
async *streamRequest(
|
|
2546
|
+
request: Record<string, unknown>,
|
|
2547
|
+
timeouts: CodexWebSocketRequestTimeouts,
|
|
2548
|
+
signal?: AbortSignal,
|
|
2549
|
+
onSseEvent?: (event: RawSseEvent) => void,
|
|
2550
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
2551
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
2552
|
+
throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
|
|
2553
|
+
}
|
|
2554
|
+
if (this.#activeRequest) {
|
|
2555
|
+
throw new CodexWebSocketTransportError(`websocket request already in progress`);
|
|
2556
|
+
}
|
|
2557
|
+
if (signal?.aborted) {
|
|
2558
|
+
throw new CodexWebSocketTransportError(`request was aborted`);
|
|
2559
|
+
}
|
|
2560
|
+
this.#activeRequest = true;
|
|
2561
|
+
this.#streamObserver = onSseEvent;
|
|
2562
|
+
// Drain any non-error frames left over from a prior request before sending.
|
|
2563
|
+
// `CodexStreamProcessor.process` breaks its `for-await` on the terminal event,
|
|
2564
|
+
// which interrupts our generator at `yield next` (the post-yield `break`
|
|
2565
|
+
// never runs). Any frame that landed between the consumer's break and the
|
|
2566
|
+
// generator's `finally` lingers in `#queue` and would otherwise become the
|
|
2567
|
+
// first frame of THIS request — a stale `response.completed` would end the
|
|
2568
|
+
// turn immediately with empty output, and a stale non-progress frame would
|
|
2569
|
+
// flip `sawFirstEvent` and silently downgrade the first-event timeout to
|
|
2570
|
+
// the longer idle timeout. Transport errors are preserved so we surface
|
|
2571
|
+
// the death signal instead of writing into a dead socket.
|
|
2572
|
+
this.#dropStaleFrames();
|
|
2573
|
+
const onAbort = () => {
|
|
2574
|
+
this.close("aborted");
|
|
2575
|
+
this.#push(new CodexWebSocketTransportError(`request was aborted`));
|
|
2576
|
+
};
|
|
2577
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
2578
|
+
|
|
2579
|
+
try {
|
|
2580
|
+
const debugSession = isRequestDebugEnabled()
|
|
2581
|
+
? await createRequestDebugSession({
|
|
2582
|
+
protocol: "websocket",
|
|
2583
|
+
method: "POST",
|
|
2584
|
+
url: this.#url,
|
|
2585
|
+
headers: this.#headers,
|
|
2586
|
+
body: request,
|
|
2587
|
+
})
|
|
2588
|
+
: undefined;
|
|
2589
|
+
this.#debugResponseLog = debugSession
|
|
2590
|
+
? await debugSession.openResponseLog("WebSocket 101 Switching Protocols", this.#handshakeHeaders)
|
|
2591
|
+
: undefined;
|
|
2592
|
+
|
|
2593
|
+
const requestPayload = JSON.stringify(request);
|
|
2594
|
+
notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
|
|
2595
|
+
// Re-check liveness: the debug-session await above can outlive the socket.
|
|
2596
|
+
const socket = this.#socket;
|
|
2597
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
2598
|
+
throw new CodexWebSocketTransportError(`websocket connection is unavailable`);
|
|
2599
|
+
}
|
|
2600
|
+
try {
|
|
2601
|
+
socket.send(requestPayload);
|
|
2602
|
+
} catch (error) {
|
|
2603
|
+
throw new CodexWebSocketTransportError(
|
|
2604
|
+
`websocket send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2605
|
+
);
|
|
2606
|
+
}
|
|
2607
|
+
let sawFirstEvent = false;
|
|
2608
|
+
const { idleTimeoutMs, firstEventTimeoutMs } = timeouts;
|
|
2609
|
+
let lastProgressAt = Date.now();
|
|
2610
|
+
let lastProgressEventType: string | undefined;
|
|
2611
|
+
let lastEventAt = lastProgressAt;
|
|
2612
|
+
let lastEventType: string | undefined;
|
|
2613
|
+
// Cross-request frame guard: lock onto this response's id and reject
|
|
2614
|
+
// frames belonging to another response interleaved on the reused socket.
|
|
2615
|
+
let activeResponseId: string | undefined;
|
|
2616
|
+
let lastSequence: number | undefined;
|
|
2617
|
+
const priorResponseId = this.#lastSeenResponseId;
|
|
2618
|
+
while (true) {
|
|
2619
|
+
let timeoutMs: number | undefined;
|
|
2620
|
+
let timeoutReason: string;
|
|
2621
|
+
if (sawFirstEvent) {
|
|
2622
|
+
timeoutReason = createCodexWebSocketTimeoutMessage("idle timeout waiting for websocket", {
|
|
2623
|
+
lastEventAt,
|
|
2624
|
+
lastEventType,
|
|
2625
|
+
lastProgressAt,
|
|
2626
|
+
lastProgressEventType,
|
|
2627
|
+
});
|
|
2628
|
+
if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) {
|
|
2629
|
+
timeoutMs = idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
2630
|
+
if (timeoutMs <= 0) {
|
|
2631
|
+
CODEX_DEBUG &&
|
|
2632
|
+
logger.debug("[codex] codex websocket idle timeout", {
|
|
2633
|
+
lastEventType,
|
|
2634
|
+
lastProgressEventType,
|
|
2635
|
+
msSinceLastEvent: Date.now() - lastEventAt,
|
|
2636
|
+
msSinceLastProgress: Date.now() - lastProgressAt,
|
|
2637
|
+
});
|
|
2638
|
+
throw new CodexWebSocketTransportError(`${timeoutReason}`);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
} else {
|
|
2642
|
+
timeoutReason = createCodexWebSocketTimeoutMessage("timeout waiting for first websocket event", {
|
|
2643
|
+
lastEventAt,
|
|
2644
|
+
lastEventType,
|
|
2645
|
+
lastProgressAt,
|
|
2646
|
+
lastProgressEventType,
|
|
2647
|
+
});
|
|
2648
|
+
if (firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0) {
|
|
2649
|
+
timeoutMs = firstEventTimeoutMs;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
const next = await this.#nextMessage(timeoutMs, timeoutReason);
|
|
2653
|
+
if (next instanceof Error) {
|
|
2654
|
+
throw next;
|
|
2655
|
+
}
|
|
2656
|
+
if (next === null) {
|
|
2657
|
+
throw new CodexWebSocketTransportError(`websocket closed before response completion`);
|
|
2658
|
+
}
|
|
2659
|
+
const eventType = typeof next.type === "string" ? next.type : "";
|
|
2660
|
+
// Cross-request frame guard. The socket is reused across turns. Upstream
|
|
2661
|
+
// codex-rs leans on the protocol guarantee that nothing follows a
|
|
2662
|
+
// response's terminal event, but our queue can still surface a trailing
|
|
2663
|
+
// or duplicate frame from a cleanly-completed prior response after
|
|
2664
|
+
// #dropStaleFrames() drained the queue at send time. Attaching such a
|
|
2665
|
+
// frame to THIS turn misattributes an earlier turn's output (a stale
|
|
2666
|
+
// `response.completed` ends the turn early; a stale item makes the model
|
|
2667
|
+
// see an unrelated call). Only lifecycle events (created/completed/
|
|
2668
|
+
// failed/incomplete) carry a `response.id` — exactly the harmful ones —
|
|
2669
|
+
// so key the guard on it and let idless frames (deltas, the rate-limit/
|
|
2670
|
+
// metadata preamble, created-less streams) pass through, matching
|
|
2671
|
+
// upstream rather than gating on `response.created`.
|
|
2672
|
+
const frameResponseId = extractCodexFrameResponseId(next);
|
|
2673
|
+
const frameSequence = extractCodexFrameSequenceNumber(next);
|
|
2674
|
+
if (frameResponseId !== undefined) {
|
|
2675
|
+
if (activeResponseId === undefined) {
|
|
2676
|
+
if (priorResponseId !== undefined && frameResponseId === priorResponseId) {
|
|
2677
|
+
// Trailing/duplicate frame of the previous response that
|
|
2678
|
+
// outlived the drain. Drop without locking or advancing the
|
|
2679
|
+
// first-event clocks so our own response can still start.
|
|
2680
|
+
continue;
|
|
2681
|
+
}
|
|
2682
|
+
activeResponseId = frameResponseId;
|
|
2683
|
+
} else if (frameResponseId !== activeResponseId) {
|
|
2684
|
+
// A different response is interleaving on the socket; the idless
|
|
2685
|
+
// deltas that follow are indistinguishable, so fail closed
|
|
2686
|
+
// (retryable) instead of risking misattribution.
|
|
2687
|
+
this.close("stale-frame");
|
|
2688
|
+
throw new CodexWebSocketTransportError(
|
|
2689
|
+
`websocket frame for response ${frameResponseId} interleaved into active response ${activeResponseId}`,
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
this.#lastSeenResponseId = frameResponseId;
|
|
2693
|
+
}
|
|
2694
|
+
if (frameSequence !== undefined) {
|
|
2695
|
+
if (activeResponseId !== undefined && lastSequence !== undefined && frameSequence < lastSequence) {
|
|
2696
|
+
this.close("stale-frame");
|
|
2697
|
+
throw new CodexWebSocketTransportError(
|
|
2698
|
+
`websocket sequence_number ${frameSequence} regressed below ${lastSequence} within response ${activeResponseId}`,
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
lastSequence = frameSequence;
|
|
2702
|
+
}
|
|
2703
|
+
sawFirstEvent = true;
|
|
2704
|
+
lastEventAt = Date.now();
|
|
2705
|
+
lastEventType = eventType || undefined;
|
|
2706
|
+
if (isCodexStreamProgressEvent(next)) {
|
|
2707
|
+
lastProgressAt = lastEventAt;
|
|
2708
|
+
lastProgressEventType = lastEventType;
|
|
2709
|
+
}
|
|
2710
|
+
yield next;
|
|
2711
|
+
if (
|
|
2712
|
+
eventType === "response.completed" ||
|
|
2713
|
+
eventType === "response.done" ||
|
|
2714
|
+
eventType === "response.incomplete" ||
|
|
2715
|
+
eventType === "response.failed" ||
|
|
2716
|
+
eventType === "error"
|
|
2717
|
+
) {
|
|
2718
|
+
break;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
} finally {
|
|
2722
|
+
this.#activeRequest = false;
|
|
2723
|
+
this.#streamObserver = undefined;
|
|
2724
|
+
if (signal) {
|
|
2725
|
+
signal.removeEventListener("abort", onAbort);
|
|
2726
|
+
}
|
|
2727
|
+
const debugResponseLog = this.#debugResponseLog;
|
|
2728
|
+
this.#debugResponseLog = undefined;
|
|
2729
|
+
await debugResponseLog?.close();
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
#captureHandshakeHeaders(socket: Bun.WebSocket, openEvent?: Event): void {
|
|
2734
|
+
const headers = extractCodexWebSocketHandshakeHeaders(socket, openEvent);
|
|
2735
|
+
if (!headers) return;
|
|
2736
|
+
this.#handshakeHeaders = headers;
|
|
2737
|
+
this.#onHandshakeHeaders?.(headers);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
#writeDebugWebSocketFrame(data: unknown): void {
|
|
2741
|
+
const log = this.#debugResponseLog;
|
|
2742
|
+
if (!log) return;
|
|
2743
|
+
if (typeof data === "string") {
|
|
2744
|
+
log.write(data);
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
if (data instanceof Uint8Array) {
|
|
2748
|
+
log.write(data);
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
if (data instanceof ArrayBuffer) {
|
|
2752
|
+
log.write(new Uint8Array(data));
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
log.write(String(data));
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
#startHeartbeat(socket: Bun.WebSocket): void {
|
|
2759
|
+
this.#stopHeartbeat();
|
|
2760
|
+
const intervalMs = CODEX_WEBSOCKET_PING_INTERVAL_MS;
|
|
2761
|
+
if (intervalMs <= 0) return;
|
|
2762
|
+
|
|
2763
|
+
this.#lastPingAt = 0;
|
|
2764
|
+
const socketEventTarget = socket as EventTarget;
|
|
2765
|
+
const onPong = () => {
|
|
2766
|
+
// Pongs are inbound activity — refresh the reuse-health clock so a quiet
|
|
2767
|
+
// but ping-responsive socket stays trustworthy across requests.
|
|
2768
|
+
this.#lastInboundAt = Date.now();
|
|
2769
|
+
};
|
|
2770
|
+
if (
|
|
2771
|
+
typeof socketEventTarget.addEventListener === "function" &&
|
|
2772
|
+
typeof socketEventTarget.removeEventListener === "function"
|
|
2773
|
+
) {
|
|
2774
|
+
socketEventTarget.addEventListener("pong", onPong);
|
|
2775
|
+
this.#removePongListener = () => socketEventTarget.removeEventListener("pong", onPong);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
this.#heartbeatInterval = setInterval(() => {
|
|
2779
|
+
if (this.#socket !== socket || socket.readyState !== WebSocket.OPEN) {
|
|
2780
|
+
this.#stopHeartbeat();
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
// Fail-closed on missing pongs even when no pong has ever been observed.
|
|
2784
|
+
// The previous `#observedPong &&` guard disabled the timeout entirely on
|
|
2785
|
+
// runtimes where Bun does not surface a `pong` event for our outgoing
|
|
2786
|
+
// pings (issue #1450) — letting truly dead sockets sail through the
|
|
2787
|
+
// pool until the per-request first-event / idle timeout (60–300 s)
|
|
2788
|
+
// finally fired. Instead, trigger on inbound silence: if we sent a
|
|
2789
|
+
// ping at least `pongTimeoutMs` ago and have received no traffic of
|
|
2790
|
+
// any kind (data frame or pong) since, the socket is unhealthy.
|
|
2791
|
+
const pongTimeoutMs = CODEX_WEBSOCKET_PONG_TIMEOUT_MS;
|
|
2792
|
+
if (
|
|
2793
|
+
pongTimeoutMs > 0 &&
|
|
2794
|
+
this.#lastPingAt > 0 &&
|
|
2795
|
+
this.#lastPingAt > this.#lastInboundAt &&
|
|
2796
|
+
Date.now() - this.#lastPingAt > pongTimeoutMs
|
|
2797
|
+
) {
|
|
2798
|
+
this.#failQueue(new CodexWebSocketTransportError(`websocket pong timeout`), "pong-timeout");
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
if (typeof socket.ping !== "function") {
|
|
2802
|
+
this.#stopHeartbeat();
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
try {
|
|
2806
|
+
socket.ping();
|
|
2807
|
+
this.#lastPingAt = Date.now();
|
|
2808
|
+
} catch (error) {
|
|
2809
|
+
this.#failQueue(
|
|
2810
|
+
new CodexWebSocketTransportError(
|
|
2811
|
+
`websocket ping failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2812
|
+
),
|
|
2813
|
+
"ping-failed",
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
}, intervalMs);
|
|
2817
|
+
this.#heartbeatInterval.unref();
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
#stopHeartbeat(): void {
|
|
2821
|
+
if (this.#heartbeatInterval) {
|
|
2822
|
+
clearInterval(this.#heartbeatInterval);
|
|
2823
|
+
this.#heartbeatInterval = undefined;
|
|
2824
|
+
}
|
|
2825
|
+
if (this.#removePongListener) {
|
|
2826
|
+
this.#removePongListener();
|
|
2827
|
+
this.#removePongListener = undefined;
|
|
2828
|
+
}
|
|
2829
|
+
this.#lastPingAt = 0;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
#failQueue(error: Error, closeReason: string): void {
|
|
2833
|
+
CODEX_DEBUG && logger.debug("[codex] codex websocket transport failure", { error: error.message, closeReason });
|
|
2834
|
+
this.#queue.length = 0;
|
|
2835
|
+
this.#queue.push(error);
|
|
2836
|
+
this.close(closeReason);
|
|
2837
|
+
this.#wakeWaiters();
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Discard data frames from a previous request that remained in `#queue`
|
|
2842
|
+
* after the consumer broke out on the terminal event. Preserves any queued
|
|
2843
|
+
* transport error (from `onerror` / `onclose` / `#failQueue`) so the next
|
|
2844
|
+
* `#nextMessage` surfaces the death signal instead of waiting it out.
|
|
2845
|
+
*
|
|
2846
|
+
* Returns the number of frames dropped (test/debug visibility only).
|
|
2847
|
+
*/
|
|
2848
|
+
#dropStaleFrames(): number {
|
|
2849
|
+
if (this.#queue.length === 0) return 0;
|
|
2850
|
+
const surviving = this.#queue.filter(item => item instanceof Error);
|
|
2851
|
+
const dropped = this.#queue.length - surviving.length;
|
|
2852
|
+
if (dropped === 0) return 0;
|
|
2853
|
+
this.#queue.length = 0;
|
|
2854
|
+
for (const item of surviving) this.#queue.push(item);
|
|
2855
|
+
CODEX_DEBUG && logger.debug("[codex] codex websocket dropped stale frames before request", { dropped });
|
|
2856
|
+
return dropped;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
#wakeWaiters(): void {
|
|
2860
|
+
for (;;) {
|
|
2861
|
+
const waiter = this.#waiters.shift();
|
|
2862
|
+
if (!waiter) break;
|
|
2863
|
+
waiter();
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
#push(item: Record<string, unknown> | Error | null): void {
|
|
2868
|
+
if (item instanceof Error) {
|
|
2869
|
+
// Append after frames already received instead of wiping them: a queued
|
|
2870
|
+
// terminal event (e.g. `response.completed` followed by an eager server
|
|
2871
|
+
// close) must still reach the consumer rather than morph into a spurious
|
|
2872
|
+
// transport failure. `#dropStaleFrames` keeps errors across requests, so
|
|
2873
|
+
// the death signal still surfaces if the data frames go unconsumed.
|
|
2874
|
+
this.#queue.push(item);
|
|
2875
|
+
this.#wakeWaiters();
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
if (item !== null && this.#queue.length >= CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY) {
|
|
2879
|
+
this.#failQueue(
|
|
2880
|
+
new CodexWebSocketTransportError(
|
|
2881
|
+
`websocket message queue exceeded ${CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY} items`,
|
|
2882
|
+
),
|
|
2883
|
+
"queue-overflow",
|
|
2884
|
+
);
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
this.#queue.push(item);
|
|
2888
|
+
const waiter = this.#waiters.shift();
|
|
2889
|
+
if (waiter) waiter();
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
async #nextMessage(
|
|
2893
|
+
timeoutMs: number | undefined,
|
|
2894
|
+
timeoutReason: string,
|
|
2895
|
+
): Promise<Record<string, unknown> | Error | null> {
|
|
2896
|
+
while (this.#queue.length === 0) {
|
|
2897
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
2898
|
+
this.#waiters.push(resolve);
|
|
2899
|
+
let timedOut = false;
|
|
2900
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
2901
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
2902
|
+
timeout = setTimeout(() => {
|
|
2903
|
+
timedOut = true;
|
|
2904
|
+
const waiterIndex = this.#waiters.indexOf(resolve);
|
|
2905
|
+
if (waiterIndex >= 0) {
|
|
2906
|
+
this.#waiters.splice(waiterIndex, 1);
|
|
2907
|
+
}
|
|
2908
|
+
resolve();
|
|
2909
|
+
}, timeoutMs);
|
|
2910
|
+
}
|
|
2911
|
+
await promise;
|
|
2912
|
+
if (timeout) clearTimeout(timeout);
|
|
2913
|
+
if (timedOut && this.#queue.length === 0) {
|
|
2914
|
+
return new CodexWebSocketTransportError(`${timeoutReason}`);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return this.#queue.shift() ?? null;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
async function getOrCreateCodexWebSocketConnection(
|
|
2922
|
+
state: CodexWebSocketSessionState,
|
|
2923
|
+
url: string,
|
|
2924
|
+
headers: Headers,
|
|
2925
|
+
signal?: AbortSignal,
|
|
2926
|
+
): Promise<CodexWebSocketConnection> {
|
|
2927
|
+
const headerRecord = headersToRecord(headers);
|
|
2928
|
+
// Join an in-flight handshake instead of tearing it down: closing a
|
|
2929
|
+
// CONNECTING socket rejects the concurrent caller (prewarm racing the first
|
|
2930
|
+
// request) with a fatal "websocket closed before open", which would disable
|
|
2931
|
+
// websockets for the entire session.
|
|
2932
|
+
// Bounded re-join: a fresh handshake may have been started by yet another
|
|
2933
|
+
// caller while we awaited the previous one.
|
|
2934
|
+
for (let joinAttempt = 0; joinAttempt < 3; joinAttempt += 1) {
|
|
2935
|
+
const pending = state.connection;
|
|
2936
|
+
if (!pending || pending.isOpen() || !pending.isConnecting()) break;
|
|
2937
|
+
try {
|
|
2938
|
+
await pending.connect(signal);
|
|
2939
|
+
} catch {
|
|
2940
|
+
// The handshake owner surfaces its own failure; re-evaluate below
|
|
2941
|
+
// (state.connection may have been replaced or cleared).
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
if (state.connection?.isOpen()) {
|
|
2945
|
+
if (!state.connection.matchesAuth(headerRecord)) {
|
|
2946
|
+
state.connection.close("token-refresh");
|
|
2947
|
+
resetCodexWebSocketAppendState(state);
|
|
2948
|
+
} else if (state.connection.isHealthyForReuse()) {
|
|
2949
|
+
logger.time("codexWs:reuseOpenSocket");
|
|
2950
|
+
return state.connection;
|
|
2951
|
+
} else {
|
|
2952
|
+
// Open in readyState but no inbound traffic recently — likely server-
|
|
2953
|
+
// evicted (issue #1450). Force a fresh handshake instead of writing
|
|
2954
|
+
// `response.create` into a half-open buffer and waiting out the
|
|
2955
|
+
// first-event timeout. Drop append state because the new socket
|
|
2956
|
+
// won't carry the prior `previous_response_id` context.
|
|
2957
|
+
CODEX_DEBUG && logger.debug("[codex] codex websocket reuse rejected by health check", {});
|
|
2958
|
+
state.connection.close("stale-reuse");
|
|
2959
|
+
resetCodexWebSocketAppendState(state);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
state.connection?.close("reconnect");
|
|
2963
|
+
resetCodexWebSocketAppendState(state);
|
|
2964
|
+
logger.time("codexWs:newSocket");
|
|
2965
|
+
state.connection = new CodexWebSocketConnection(url, headerRecord, {
|
|
2966
|
+
onHandshakeHeaders: handshakeHeaders => {
|
|
2967
|
+
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
|
|
2968
|
+
},
|
|
2969
|
+
});
|
|
2970
|
+
await state.connection.connect(signal);
|
|
2971
|
+
return state.connection;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
async function openCodexSseEventStream(
|
|
2975
|
+
url: string,
|
|
2976
|
+
requestHeaders: Record<string, string> | undefined,
|
|
2977
|
+
accountId: string,
|
|
2978
|
+
apiKey: string,
|
|
2979
|
+
sessionId: string | undefined,
|
|
2980
|
+
body: RequestBody,
|
|
2981
|
+
state: CodexWebSocketSessionState | undefined,
|
|
2982
|
+
responsesLite: boolean,
|
|
2983
|
+
signal: AbortSignal | undefined,
|
|
2984
|
+
firstEventTimeoutMs: number | undefined,
|
|
2985
|
+
onSseEvent?: OpenAICodexResponsesOptions["onSseEvent"],
|
|
2986
|
+
fetchOverride?: FetchImpl,
|
|
2987
|
+
): Promise<AsyncGenerator<Record<string, unknown>>> {
|
|
2988
|
+
const headers = createCodexHeaders(requestHeaders, accountId, apiKey, sessionId, "sse", state, responsesLite);
|
|
2989
|
+
CODEX_DEBUG &&
|
|
2990
|
+
logger.debug("[codex] codex request", {
|
|
2991
|
+
url,
|
|
2992
|
+
model: body.model,
|
|
2993
|
+
headers: redactHeaders(headers),
|
|
2994
|
+
sentTurnStateHeader: headers.has(X_CODEX_TURN_STATE_HEADER),
|
|
2995
|
+
sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER),
|
|
2996
|
+
});
|
|
2997
|
+
// `wrapCodexSseStream` arms the iterator-level idle watchdog only after this
|
|
2998
|
+
// fetch resolves. A pre-response timer still bounds time-to-first-byte (a
|
|
2999
|
+
// proxy that accepts the POST but never sends headers would otherwise hang
|
|
3000
|
+
// forever, since `timeout: false` disables Bun's native ceiling — issue
|
|
3001
|
+
// #2422). It MUST be cleared the instant headers arrive: an absolute
|
|
3002
|
+
// `AbortSignal.timeout` would keep aborting the actively-streaming body.
|
|
3003
|
+
const watchdog = armPreResponseTimeout(signal, firstEventTimeoutMs);
|
|
3004
|
+
let response: Response;
|
|
3005
|
+
try {
|
|
3006
|
+
response = await fetchWithRetry(url, {
|
|
3007
|
+
method: "POST",
|
|
3008
|
+
headers,
|
|
3009
|
+
body: JSON.stringify(body),
|
|
3010
|
+
signal: watchdog.signal,
|
|
3011
|
+
maxAttempts: CODEX_MAX_RETRIES + 1,
|
|
3012
|
+
defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1),
|
|
3013
|
+
maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS,
|
|
3014
|
+
fetch: fetchOverride,
|
|
3015
|
+
timeout: false,
|
|
3016
|
+
});
|
|
3017
|
+
} finally {
|
|
3018
|
+
watchdog.clear();
|
|
3019
|
+
}
|
|
3020
|
+
CODEX_DEBUG &&
|
|
3021
|
+
logger.debug("[codex] codex response", {
|
|
3022
|
+
url: response.url,
|
|
3023
|
+
status: response.status,
|
|
3024
|
+
statusText: response.statusText,
|
|
3025
|
+
contentType: response.headers.get("content-type") || null,
|
|
3026
|
+
cfRay: response.headers.get("cf-ray") || null,
|
|
3027
|
+
});
|
|
3028
|
+
if (!response.ok) {
|
|
3029
|
+
throw await CodexApiError.fromResponse(response);
|
|
3030
|
+
}
|
|
3031
|
+
updateCodexSessionMetadataFromHeaders(state, response.headers);
|
|
3032
|
+
if (!response.body) {
|
|
3033
|
+
throw new CodexProviderStreamError("No response body", false);
|
|
3034
|
+
}
|
|
3035
|
+
return readSseJson<Record<string, unknown>>(response.body, signal, event =>
|
|
3036
|
+
onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, undefined),
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
function createCodexHeaders(
|
|
3041
|
+
initHeaders: Record<string, string> | undefined,
|
|
3042
|
+
accountId: string,
|
|
3043
|
+
accessToken: string,
|
|
3044
|
+
sessionId?: string,
|
|
3045
|
+
transport: CodexTransport = "sse",
|
|
3046
|
+
state?: CodexWebSocketSessionState,
|
|
3047
|
+
responsesLite = false,
|
|
3048
|
+
): Headers {
|
|
3049
|
+
const headers = new Headers(initHeaders ?? {});
|
|
3050
|
+
headers.delete("x-api-key");
|
|
3051
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
3052
|
+
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
|
|
3053
|
+
const betaHeader =
|
|
3054
|
+
transport === "websocket"
|
|
3055
|
+
? OPENAI_HEADER_VALUES.BETA_RESPONSES_WEBSOCKETS_V2
|
|
3056
|
+
: OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
3057
|
+
headers.delete(OPENAI_HEADERS.BETA);
|
|
3058
|
+
headers.delete("openai-beta");
|
|
3059
|
+
headers.set(OPENAI_HEADERS.BETA, betaHeader);
|
|
3060
|
+
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
|
3061
|
+
headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
|
|
3062
|
+
if (sessionId) {
|
|
3063
|
+
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
|
|
3064
|
+
headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
|
|
3065
|
+
headers.set("x-client-request-id", sessionId);
|
|
3066
|
+
} else {
|
|
3067
|
+
headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
|
|
3068
|
+
headers.delete(OPENAI_HEADERS.SESSION_ID);
|
|
3069
|
+
headers.delete("x-client-request-id");
|
|
3070
|
+
}
|
|
3071
|
+
if (state?.turnState) {
|
|
3072
|
+
headers.set(X_CODEX_TURN_STATE_HEADER, state.turnState);
|
|
3073
|
+
} else {
|
|
3074
|
+
headers.delete(X_CODEX_TURN_STATE_HEADER);
|
|
3075
|
+
}
|
|
3076
|
+
if (state?.modelsEtag) {
|
|
3077
|
+
headers.set(X_MODELS_ETAG_HEADER, state.modelsEtag);
|
|
3078
|
+
} else {
|
|
3079
|
+
headers.delete(X_MODELS_ETAG_HEADER);
|
|
3080
|
+
}
|
|
3081
|
+
if (responsesLite) {
|
|
3082
|
+
headers.set(X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, "true");
|
|
3083
|
+
} else {
|
|
3084
|
+
headers.delete(X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER);
|
|
3085
|
+
}
|
|
3086
|
+
if (transport === "sse") {
|
|
3087
|
+
headers.set("accept", "text/event-stream");
|
|
3088
|
+
headers.set("content-type", "application/json");
|
|
3089
|
+
} else {
|
|
3090
|
+
headers.delete("accept");
|
|
3091
|
+
headers.delete("content-type");
|
|
3092
|
+
}
|
|
3093
|
+
return headers;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
function redactHeaders(headers: Headers): Record<string, string> {
|
|
3097
|
+
const redacted: Record<string, string> = {};
|
|
3098
|
+
for (const [key, value] of headers.entries()) {
|
|
3099
|
+
const lower = key.toLowerCase();
|
|
3100
|
+
if (lower === "authorization") {
|
|
3101
|
+
redacted[key] = "Bearer [redacted]";
|
|
3102
|
+
continue;
|
|
3103
|
+
}
|
|
3104
|
+
if (
|
|
3105
|
+
lower.includes("account") ||
|
|
3106
|
+
lower.includes("session") ||
|
|
3107
|
+
lower.includes("conversation") ||
|
|
3108
|
+
lower === "x-client-request-id" ||
|
|
3109
|
+
lower === "cookie"
|
|
3110
|
+
) {
|
|
3111
|
+
redacted[key] = "[redacted]";
|
|
3112
|
+
continue;
|
|
3113
|
+
}
|
|
3114
|
+
redacted[key] = value;
|
|
3115
|
+
}
|
|
3116
|
+
return redacted;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
|
|
3120
|
+
const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : CODEX_BASE_URL;
|
|
3121
|
+
const normalized = raw.replace(/\/+$/, "");
|
|
3122
|
+
if (normalized.endsWith("/codex/responses")) return normalized;
|
|
3123
|
+
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
|
|
3124
|
+
return `${normalized}/codex/responses`;
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
function getAccountId(accessToken: string): string {
|
|
3128
|
+
const accountId = getCodexAccountId(accessToken);
|
|
3129
|
+
if (!accountId) {
|
|
3130
|
+
throw new AIError.OAuthError("Failed to extract accountId from token", {
|
|
3131
|
+
kind: "validation",
|
|
3132
|
+
provider: "openai",
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
return accountId;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput {
|
|
3139
|
+
const messages: ResponseInput = [];
|
|
3140
|
+
|
|
3141
|
+
const normalizeToolCallId = (id: string): string => {
|
|
3142
|
+
if (!id.includes("|")) return id;
|
|
3143
|
+
const [callId, itemId] = id.split("|");
|
|
3144
|
+
const sanitizedCallId = callId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
3145
|
+
let sanitizedItemId = itemId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
3146
|
+
if (!sanitizedItemId.startsWith("fc")) {
|
|
3147
|
+
sanitizedItemId = `fc_${sanitizedItemId}`;
|
|
3148
|
+
}
|
|
3149
|
+
let normalizedCallId = sanitizedCallId.length > 64 ? sanitizedCallId.slice(0, 64) : sanitizedCallId;
|
|
3150
|
+
let normalizedItemId = sanitizedItemId.length > 64 ? sanitizedItemId.slice(0, 64) : sanitizedItemId;
|
|
3151
|
+
normalizedCallId = normalizedCallId.replace(/_+$/, "");
|
|
3152
|
+
normalizedItemId = normalizedItemId.replace(/_+$/, "");
|
|
3153
|
+
return `${normalizedCallId}|${normalizedItemId}`;
|
|
3154
|
+
};
|
|
3155
|
+
|
|
3156
|
+
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
|
3157
|
+
let msgIndex = 0;
|
|
3158
|
+
// Track call_ids that originated as custom tool calls so paired tool-result
|
|
3159
|
+
// messages can be replayed as `custom_tool_call_output` rather than
|
|
3160
|
+
// `function_call_output` (OpenAI rejects mismatched pairs).
|
|
3161
|
+
const customCallIds = new Set<string>();
|
|
3162
|
+
const knownCallIds = new Set<string>();
|
|
3163
|
+
|
|
3164
|
+
for (const msg of transformedMessages) {
|
|
3165
|
+
if (msg.role === "user" || msg.role === "developer") {
|
|
3166
|
+
const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
|
|
3167
|
+
const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider) as
|
|
3168
|
+
| Array<ResponseInput[number]>
|
|
3169
|
+
| undefined;
|
|
3170
|
+
if (historyItems) {
|
|
3171
|
+
for (const item of historyItems) {
|
|
3172
|
+
const maybe = item as { type?: string; call_id?: string };
|
|
3173
|
+
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
|
|
3174
|
+
customCallIds.add(maybe.call_id);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
messages.push(...historyItems);
|
|
3178
|
+
msgIndex += 1;
|
|
3179
|
+
continue;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
const normalizedContent = normalizeInputMessageContent(model, msg.content);
|
|
3183
|
+
if (normalizedContent.length === 0) continue;
|
|
3184
|
+
messages.push({ role: msg.role, content: normalizedContent });
|
|
3185
|
+
msgIndex += 1;
|
|
3186
|
+
continue;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
if (msg.role === "assistant") {
|
|
3190
|
+
const assistantMsg = msg as AssistantMessage;
|
|
3191
|
+
// Native items are model-bound (reasoning carries encrypted content
|
|
3192
|
+
// minted by the producing model); after a mid-session model switch fall
|
|
3193
|
+
// back to block re-encode, which strips foreign signatures.
|
|
3194
|
+
const providerPayload =
|
|
3195
|
+
assistantMsg.api === model.api && assistantMsg.model === model.id
|
|
3196
|
+
? getOpenAIResponsesHistoryPayload(assistantMsg.providerPayload, model.provider, assistantMsg.provider)
|
|
3197
|
+
: undefined;
|
|
3198
|
+
const historyItems = providerPayload?.items as Array<ResponseInput[number]> | undefined;
|
|
3199
|
+
if (historyItems) {
|
|
3200
|
+
for (const item of historyItems) {
|
|
3201
|
+
const maybe = item as { type?: string; call_id?: string };
|
|
3202
|
+
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
|
|
3203
|
+
customCallIds.add(maybe.call_id);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
if (providerPayload?.dt) {
|
|
3207
|
+
messages.push(...historyItems);
|
|
3208
|
+
} else {
|
|
3209
|
+
messages.splice(0, messages.length, ...historyItems);
|
|
3210
|
+
// Keep customCallIds from the pre-splice state since historyItems may re-introduce them.
|
|
3211
|
+
}
|
|
3212
|
+
msgIndex += 1;
|
|
3213
|
+
continue;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
const outputItems = convertResponsesAssistantMessage(
|
|
3217
|
+
msg as AssistantMessage,
|
|
3218
|
+
model,
|
|
3219
|
+
msgIndex,
|
|
3220
|
+
knownCallIds,
|
|
3221
|
+
true,
|
|
3222
|
+
customCallIds,
|
|
3223
|
+
);
|
|
3224
|
+
if (outputItems.length > 0) {
|
|
3225
|
+
messages.push(...outputItems);
|
|
3226
|
+
}
|
|
3227
|
+
msgIndex += 1;
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
if (msg.role === "toolResult") {
|
|
3232
|
+
appendResponsesToolResultMessages(
|
|
3233
|
+
messages,
|
|
3234
|
+
msg,
|
|
3235
|
+
model,
|
|
3236
|
+
false,
|
|
3237
|
+
model.compat.supportsImageDetailOriginal,
|
|
3238
|
+
knownCallIds,
|
|
3239
|
+
customCallIds,
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
msgIndex += 1;
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
return messages;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
function normalizeInputMessageContent(
|
|
3250
|
+
model: Model<"openai-codex-responses">,
|
|
3251
|
+
content: string | Array<{ type: "text"; text: string } | { type: "image"; mimeType: string; data: string }>,
|
|
3252
|
+
): ResponseInputContent[] {
|
|
3253
|
+
if (typeof content === "string") {
|
|
3254
|
+
if (!content || content.trim() === "") return [];
|
|
3255
|
+
return [{ type: "input_text", text: content.toWellFormed() }];
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
return (
|
|
3259
|
+
convertResponsesInputContent(content, model.input.includes("image"), model.compat.supportsImageDetailOriginal) ??
|
|
3260
|
+
[]
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** @internal Exported for tests. */
|
|
3265
|
+
export { convertMessages as convertCodexResponsesMessages };
|
|
3266
|
+
|
|
3267
|
+
type CodexToolPayload =
|
|
3268
|
+
| {
|
|
3269
|
+
type: "function";
|
|
3270
|
+
name: string;
|
|
3271
|
+
description: string;
|
|
3272
|
+
parameters: Record<string, unknown>;
|
|
3273
|
+
strict?: boolean;
|
|
3274
|
+
}
|
|
3275
|
+
| {
|
|
3276
|
+
type: "custom";
|
|
3277
|
+
name: string;
|
|
3278
|
+
description: string;
|
|
3279
|
+
format: { type: "grammar"; syntax: "lark" | "regex"; definition: string };
|
|
3280
|
+
};
|
|
3281
|
+
|
|
3282
|
+
/** @internal Exported for tests. */
|
|
3283
|
+
export function convertOpenAICodexResponsesTools(
|
|
3284
|
+
tools: Tool[],
|
|
3285
|
+
model: Model<"openai-codex-responses">,
|
|
3286
|
+
): CodexToolPayload[] {
|
|
3287
|
+
const allowFreeform = model.applyPatchToolType === "freeform";
|
|
3288
|
+
return tools.map((tool): CodexToolPayload => {
|
|
3289
|
+
if (allowFreeform && tool.customFormat) {
|
|
3290
|
+
return {
|
|
3291
|
+
type: "custom",
|
|
3292
|
+
name: tool.customWireName ?? tool.name,
|
|
3293
|
+
description: tool.description || "",
|
|
3294
|
+
format: {
|
|
3295
|
+
type: "grammar",
|
|
3296
|
+
syntax: tool.customFormat.syntax,
|
|
3297
|
+
definition: compactGrammarDefinition(tool.customFormat.syntax, tool.customFormat.definition),
|
|
3298
|
+
},
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
const strict = !!(!NO_STRICT && tool.strict);
|
|
3302
|
+
const baseParameters = sanitizeSchemaForOpenAIResponses(toolWireSchema(tool));
|
|
3303
|
+
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
|
|
3304
|
+
return {
|
|
3305
|
+
type: "function",
|
|
3306
|
+
name: tool.name,
|
|
3307
|
+
description: tool.description || "",
|
|
3308
|
+
parameters,
|
|
3309
|
+
...(effectiveStrict && { strict: true }),
|
|
3310
|
+
};
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
export class CodexWebSocketTransportError extends Error {
|
|
3315
|
+
constructor(detail: string) {
|
|
3316
|
+
super(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${detail}`);
|
|
3317
|
+
this.name = "CodexWebSocketTransportError";
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
class CodexWhitespaceToolCallLoopError extends Error {
|
|
3321
|
+
constructor(message: string) {
|
|
3322
|
+
super(message);
|
|
3323
|
+
this.name = "CodexWhitespaceToolCallLoopError";
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
class CodexProviderStreamError extends Error {
|
|
3328
|
+
readonly retryable: boolean;
|
|
3329
|
+
readonly code?: string;
|
|
3330
|
+
|
|
3331
|
+
constructor(message: string, retryable: boolean, code?: string) {
|
|
3332
|
+
super(message);
|
|
3333
|
+
this.name = "CodexProviderStreamError";
|
|
3334
|
+
this.retryable = retryable;
|
|
3335
|
+
this.code = code;
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
const optionalCodexString = type("unknown").pipe(raw => {
|
|
3340
|
+
const out = type("string")(raw);
|
|
3341
|
+
return out instanceof type.errors ? undefined : out;
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
const innerErrorDetailSchema = type({
|
|
3345
|
+
"code?": optionalCodexString,
|
|
3346
|
+
"type?": optionalCodexString,
|
|
3347
|
+
"message?": optionalCodexString,
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
const codexErrorDetailSchema = type("unknown").pipe(raw => {
|
|
3351
|
+
const out = innerErrorDetailSchema(raw);
|
|
3352
|
+
return out instanceof type.errors ? undefined : out;
|
|
3353
|
+
});
|
|
3354
|
+
|
|
3355
|
+
const innerFailureEventSchema = type({
|
|
3356
|
+
"type?": optionalCodexString,
|
|
3357
|
+
"code?": optionalCodexString,
|
|
3358
|
+
"message?": optionalCodexString,
|
|
3359
|
+
"status?": optionalCodexString,
|
|
3360
|
+
"error?": codexErrorDetailSchema,
|
|
3361
|
+
"response?": type("unknown").pipe(raw => {
|
|
3362
|
+
const out = type({
|
|
3363
|
+
"error?": codexErrorDetailSchema,
|
|
3364
|
+
"message?": optionalCodexString,
|
|
3365
|
+
"status?": optionalCodexString,
|
|
3366
|
+
})(raw);
|
|
3367
|
+
return out instanceof type.errors ? undefined : out;
|
|
3368
|
+
}),
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
const codexFailureEventSchema = type("unknown").pipe(raw => {
|
|
3372
|
+
const out = innerFailureEventSchema(raw);
|
|
3373
|
+
return out instanceof type.errors
|
|
3374
|
+
? {
|
|
3375
|
+
type: undefined,
|
|
3376
|
+
code: undefined,
|
|
3377
|
+
message: undefined,
|
|
3378
|
+
status: undefined,
|
|
3379
|
+
error: undefined,
|
|
3380
|
+
response: undefined,
|
|
3381
|
+
}
|
|
3382
|
+
: out;
|
|
3383
|
+
});
|
|
3384
|
+
|
|
3385
|
+
export function isRetryableCodexFailureEvent(rawEvent: Record<string, unknown>): boolean {
|
|
3386
|
+
const event = codexFailureEventSchema(rawEvent);
|
|
3387
|
+
if (event instanceof type.errors) {
|
|
3388
|
+
return false;
|
|
3389
|
+
}
|
|
3390
|
+
const error = event.error ?? event.response?.error;
|
|
3391
|
+
const code = error?.code ?? error?.type ?? event.code;
|
|
3392
|
+
if (code && CODEX_RETRYABLE_EVENT_CODES.has(code.toLowerCase())) {
|
|
3393
|
+
return true;
|
|
3394
|
+
}
|
|
3395
|
+
const message = error?.message ?? event.message ?? event.response?.message;
|
|
3396
|
+
return !!message && CODEX_RETRYABLE_EVENT_MESSAGE.test(message);
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
export function createCodexProviderStreamError(rawEvent: Record<string, unknown>): CodexProviderStreamError {
|
|
3400
|
+
const event = codexFailureEventSchema(rawEvent);
|
|
3401
|
+
if (event instanceof type.errors) {
|
|
3402
|
+
return new CodexProviderStreamError("Codex response failed", false);
|
|
3403
|
+
}
|
|
3404
|
+
const nestedError = event.error ?? event.response?.error;
|
|
3405
|
+
const code = nestedError?.code ?? nestedError?.type ?? event.code ?? "";
|
|
3406
|
+
const message = event.message ?? "";
|
|
3407
|
+
const formattedMessage =
|
|
3408
|
+
event.type === "error"
|
|
3409
|
+
? formatCodexErrorEvent(rawEvent, code, message)
|
|
3410
|
+
: (formatCodexFailure(rawEvent) ?? "Codex response failed");
|
|
3411
|
+
return new CodexProviderStreamError(formattedMessage, isRetryableCodexFailureEvent(rawEvent), code || undefined);
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
function formatCodexFailure(rawEvent: Record<string, unknown>): string | null {
|
|
3415
|
+
const event = codexFailureEventSchema(rawEvent);
|
|
3416
|
+
if (event instanceof type.errors) {
|
|
3417
|
+
return null;
|
|
3418
|
+
}
|
|
3419
|
+
const error = event.error ?? event.response?.error;
|
|
3420
|
+
const message = error?.message ?? event.message ?? event.response?.message;
|
|
3421
|
+
const code = error?.code ?? error?.type ?? event.code;
|
|
3422
|
+
const status = event.response?.status ?? event.status;
|
|
3423
|
+
|
|
3424
|
+
const meta: string[] = [];
|
|
3425
|
+
if (code) meta.push(`code=${code}`);
|
|
3426
|
+
if (status) meta.push(`status=${status}`);
|
|
3427
|
+
|
|
3428
|
+
if (message) {
|
|
3429
|
+
const metaText = meta.length ? ` (${meta.join(", ")})` : "";
|
|
3430
|
+
return `Codex response failed: ${message}${metaText}`;
|
|
3431
|
+
}
|
|
3432
|
+
if (meta.length) {
|
|
3433
|
+
return `Codex response failed (${meta.join(", ")})`;
|
|
3434
|
+
}
|
|
3435
|
+
try {
|
|
3436
|
+
const rawEventJson = JSON.stringify(rawEvent);
|
|
3437
|
+
const truncatedRawEventJson =
|
|
3438
|
+
rawEventJson.length <= 800
|
|
3439
|
+
? rawEventJson
|
|
3440
|
+
: `${rawEventJson.slice(0, 800)}…[truncated ${rawEventJson.length - 800}]`;
|
|
3441
|
+
return `Codex response failed: ${truncatedRawEventJson}`;
|
|
3442
|
+
} catch {
|
|
3443
|
+
return "Codex response failed";
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
function formatCodexErrorEvent(rawEvent: Record<string, unknown>, code: string, message: string): string {
|
|
3448
|
+
const detail = formatCodexFailure(rawEvent);
|
|
3449
|
+
if (detail) {
|
|
3450
|
+
return detail.replace("response failed", "error event");
|
|
3451
|
+
}
|
|
3452
|
+
const meta: string[] = [];
|
|
3453
|
+
if (code) meta.push(`code=${code}`);
|
|
3454
|
+
if (message) meta.push(`message=${message}`);
|
|
3455
|
+
if (meta.length > 0) {
|
|
3456
|
+
return `Codex error event (${meta.join(", ")})`;
|
|
3457
|
+
}
|
|
3458
|
+
try {
|
|
3459
|
+
const rawEventJson = JSON.stringify(rawEvent);
|
|
3460
|
+
const truncatedRawEventJson =
|
|
3461
|
+
rawEventJson.length <= 800
|
|
3462
|
+
? rawEventJson
|
|
3463
|
+
: `${rawEventJson.slice(0, 800)}…[truncated ${rawEventJson.length - 800}]`;
|
|
3464
|
+
return `Codex error event: ${truncatedRawEventJson}`;
|
|
3465
|
+
} catch {
|
|
3466
|
+
return "Codex error event";
|
|
3467
|
+
}
|
|
3468
|
+
}
|