@aryee337/aery-ai 0.2.27 → 0.2.29
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 +2914 -0
- package/README.md +614 -813
- package/package.json +140 -105
- package/src/api-registry.ts +96 -0
- package/src/auth-broker/client.ts +358 -0
- package/src/auth-broker/index.ts +5 -0
- package/src/auth-broker/refresher.ts +117 -0
- package/src/auth-broker/remote-store.ts +623 -0
- package/src/auth-broker/server.ts +644 -0
- package/src/auth-broker/types.ts +127 -0
- package/src/auth-broker/wire-schemas.ts +200 -0
- package/src/auth-gateway/http.ts +194 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +818 -0
- package/src/auth-gateway/types.ts +143 -0
- package/src/auth-storage.ts +4422 -0
- package/src/index.ts +54 -0
- package/src/model-cache.ts +129 -0
- package/src/model-manager.ts +469 -0
- package/src/model-thinking.ts +782 -0
- package/src/models.json +83530 -0
- package/src/models.json.d.ts +9 -0
- package/src/models.ts +56 -0
- package/src/prompts/turn-aborted-guidance.md +4 -0
- package/src/provider-details.ts +90 -0
- package/src/provider-models/bundled-references.ts +38 -0
- package/src/provider-models/descriptors.ts +355 -0
- package/src/provider-models/google.ts +88 -0
- package/src/provider-models/index.ts +5 -0
- package/src/provider-models/ollama.ts +153 -0
- package/src/provider-models/openai-compat.ts +2817 -0
- package/src/provider-models/special.ts +67 -0
- package/src/providers/aery-native-client.ts +228 -0
- package/src/providers/aery-native-server.ts +212 -0
- package/src/providers/amazon-bedrock.ts +873 -0
- package/src/providers/anthropic-client.ts +318 -0
- package/src/providers/anthropic-messages-server-schema.ts +243 -0
- package/src/providers/anthropic-messages-server.ts +683 -0
- package/src/providers/anthropic-wire.ts +268 -0
- package/src/providers/anthropic.ts +3094 -0
- package/src/providers/aws-credentials.ts +501 -0
- package/src/providers/aws-eventstream.ts +185 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +361 -0
- package/src/providers/cursor/gen/agent_pb.ts +15274 -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 +2621 -0
- package/src/providers/error-message.ts +21 -0
- package/src/providers/github-copilot-headers.ts +140 -0
- package/src/providers/gitlab-duo.ts +372 -0
- package/src/providers/google-auth.ts +252 -0
- package/src/providers/google-gemini-cli.ts +809 -0
- package/src/providers/google-gemini-headers.ts +41 -0
- package/src/providers/google-shared.ts +917 -0
- package/src/providers/google-types.ts +167 -0
- package/src/providers/google-vertex.ts +91 -0
- package/src/providers/google.ts +41 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +52 -0
- package/src/providers/mock.ts +496 -0
- package/src/providers/ollama.ts +644 -0
- package/src/providers/openai-anthropic-shim.ts +138 -0
- package/src/providers/openai-chat-server-schema.ts +252 -0
- package/src/providers/openai-chat-server.ts +647 -0
- package/src/providers/openai-codex/constants.ts +43 -0
- package/src/providers/openai-codex/request-transformer.ts +161 -0
- package/src/providers/openai-codex/response-handler.ts +81 -0
- package/src/providers/openai-codex-responses.ts +3018 -0
- package/src/providers/openai-completions-compat.ts +300 -0
- package/src/providers/openai-completions.ts +1979 -0
- package/src/providers/openai-responses-server-schema.ts +290 -0
- package/src/providers/openai-responses-server.ts +1183 -0
- package/src/providers/openai-responses-shared.ts +873 -0
- package/src/providers/openai-responses.ts +679 -0
- package/src/providers/register-builtins.ts +436 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +382 -0
- package/src/providers/vision-guard.ts +31 -0
- package/src/providers/xai-responses.ts +82 -0
- package/src/rate-limit-utils.ts +84 -0
- package/src/stream.ts +1065 -0
- package/src/types.ts +944 -0
- package/src/usage/claude.ts +482 -0
- package/src/usage/gemini.ts +250 -0
- package/src/usage/github-copilot.ts +421 -0
- package/src/usage/google-antigravity.ts +201 -0
- package/src/usage/kimi.ts +271 -0
- package/src/usage/minimax-code.ts +31 -0
- package/src/usage/openai-codex.ts +503 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/zai.ts +247 -0
- package/src/usage.ts +185 -0
- package/src/utils/abort.ts +51 -0
- package/src/utils/abortable-iterator.ts +69 -0
- package/src/utils/anthropic-auth.ts +93 -0
- package/src/utils/discovery/antigravity.ts +261 -0
- package/src/utils/discovery/codex.ts +371 -0
- package/src/utils/discovery/cursor.ts +306 -0
- package/src/utils/discovery/gemini.ts +248 -0
- package/src/utils/discovery/index.ts +4 -0
- package/src/utils/discovery/openai-compatible.ts +224 -0
- package/src/utils/event-stream.ts +142 -0
- package/src/utils/fireworks-model-id.ts +30 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/http-inspector.ts +176 -0
- package/src/utils/idle-iterator.ts +267 -0
- package/src/utils/json-parse.ts +182 -0
- package/src/utils/oauth/__tests__/xai-oauth.test.ts +107 -0
- package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
- package/src/utils/oauth/anthropic.ts +273 -0
- package/src/utils/oauth/api-key-login.ts +87 -0
- package/src/utils/oauth/api-key-validation.ts +92 -0
- package/src/utils/oauth/callback-server.ts +276 -0
- package/src/utils/oauth/cerebras.ts +16 -0
- package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
- package/src/utils/oauth/cursor.ts +157 -0
- package/src/utils/oauth/deepseek.ts +53 -0
- package/src/utils/oauth/firepass.ts +24 -0
- package/src/utils/oauth/fireworks.ts +15 -0
- package/src/utils/oauth/github-copilot.ts +362 -0
- package/src/utils/oauth/gitlab-duo.ts +123 -0
- package/src/utils/oauth/google-antigravity.ts +200 -0
- package/src/utils/oauth/google-gemini-cli.ts +256 -0
- package/src/utils/oauth/google-oauth-shared.ts +110 -0
- package/src/utils/oauth/huggingface.ts +62 -0
- package/src/utils/oauth/index.ts +484 -0
- package/src/utils/oauth/kagi.ts +47 -0
- package/src/utils/oauth/kilo.ts +87 -0
- package/src/utils/oauth/kimi.ts +254 -0
- package/src/utils/oauth/litellm.ts +47 -0
- package/src/utils/oauth/lm-studio.ts +38 -0
- package/src/utils/oauth/minimax-code.ts +78 -0
- package/src/utils/oauth/moonshot.ts +23 -0
- package/src/utils/oauth/nanogpt.ts +15 -0
- package/src/utils/oauth/nvidia.ts +70 -0
- package/src/utils/oauth/oauth.html +203 -0
- package/src/utils/oauth/ollama-cloud.ts +28 -0
- package/src/utils/oauth/ollama.ts +47 -0
- package/src/utils/oauth/openai-codex.ts +299 -0
- package/src/utils/oauth/opencode.ts +49 -0
- package/src/utils/oauth/openrouter.ts +20 -0
- package/src/utils/oauth/parallel.ts +46 -0
- package/src/utils/oauth/perplexity.ts +206 -0
- package/src/utils/oauth/pkce.ts +18 -0
- package/src/utils/oauth/qianfan.ts +58 -0
- package/src/utils/oauth/qwen-portal.ts +60 -0
- package/src/utils/oauth/synthetic.ts +15 -0
- package/src/utils/oauth/tavily.ts +46 -0
- package/src/utils/oauth/together.ts +16 -0
- package/src/utils/oauth/types.ts +99 -0
- package/src/utils/oauth/venice.ts +59 -0
- package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
- package/src/utils/oauth/vllm.ts +40 -0
- package/src/utils/oauth/wafer.ts +50 -0
- package/src/utils/oauth/xai-oauth.ts +342 -0
- package/src/utils/oauth/xiaomi.ts +139 -0
- package/src/utils/oauth/zai.ts +60 -0
- package/src/utils/oauth/zenmux.ts +15 -0
- package/src/utils/oauth/zhipu.ts +60 -0
- package/src/utils/overflow.ts +137 -0
- package/src/utils/parse-bind.ts +54 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/request-debug.ts +336 -0
- package/src/utils/retry-after.ts +110 -0
- package/src/utils/retry.ts +54 -0
- package/src/utils/schema/CONSTRAINTS.md +164 -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 +191 -0
- package/src/utils/schema/index.ts +13 -0
- package/src/utils/schema/json-schema-validator.ts +577 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +1588 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +97 -0
- package/src/utils/schema/types.ts +10 -0
- package/src/utils/schema/wire.ts +293 -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 +289 -0
- package/src/utils/stream-markup-healing.ts +612 -0
- package/src/utils/tool-choice.ts +99 -0
- package/src/utils/validation.ts +1024 -0
- package/src/utils.ts +166 -0
- package/dist/api-registry.d.ts +0 -20
- package/dist/api-registry.d.ts.map +0 -1
- package/dist/api-registry.js +0 -44
- package/dist/api-registry.js.map +0 -1
- package/dist/bedrock-provider.d.ts +0 -5
- package/dist/bedrock-provider.d.ts.map +0 -1
- package/dist/bedrock-provider.js +0 -6
- package/dist/bedrock-provider.js.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -130
- package/dist/cli.js.map +0 -1
- package/dist/env-api-keys.d.ts +0 -18
- package/dist/env-api-keys.d.ts.map +0 -1
- package/dist/env-api-keys.js +0 -178
- package/dist/env-api-keys.js.map +0 -1
- package/dist/image-models.d.ts +0 -10
- package/dist/image-models.d.ts.map +0 -1
- package/dist/image-models.generated.d.ts +0 -440
- package/dist/image-models.generated.d.ts.map +0 -1
- package/dist/image-models.generated.js +0 -442
- package/dist/image-models.generated.js.map +0 -1
- package/dist/image-models.js +0 -23
- package/dist/image-models.js.map +0 -1
- package/dist/images-api-registry.d.ts +0 -14
- package/dist/images-api-registry.d.ts.map +0 -1
- package/dist/images-api-registry.js +0 -22
- package/dist/images-api-registry.js.map +0 -1
- package/dist/images.d.ts +0 -4
- package/dist/images.d.ts.map +0 -1
- package/dist/images.js +0 -14
- package/dist/images.js.map +0 -1
- package/dist/index.d.ts +0 -32
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -20
- package/dist/index.js.map +0 -1
- package/dist/models.d.ts +0 -18
- package/dist/models.d.ts.map +0 -1
- package/dist/models.generated.d.ts +0 -17707
- package/dist/models.generated.d.ts.map +0 -1
- package/dist/models.generated.js +0 -16561
- package/dist/models.generated.js.map +0 -1
- package/dist/models.js +0 -71
- package/dist/models.js.map +0 -1
- package/dist/oauth.d.ts +0 -2
- package/dist/oauth.d.ts.map +0 -1
- package/dist/oauth.js +0 -2
- package/dist/oauth.js.map +0 -1
- package/dist/providers/aery-error-formatting.d.ts +0 -13
- package/dist/providers/aery-error-formatting.d.ts.map +0 -1
- package/dist/providers/aery-error-formatting.js +0 -112
- package/dist/providers/aery-error-formatting.js.map +0 -1
- package/dist/providers/amazon-bedrock.d.ts +0 -38
- package/dist/providers/amazon-bedrock.d.ts.map +0 -1
- package/dist/providers/amazon-bedrock.js +0 -763
- package/dist/providers/amazon-bedrock.js.map +0 -1
- package/dist/providers/anthropic.d.ts +0 -71
- package/dist/providers/anthropic.d.ts.map +0 -1
- package/dist/providers/anthropic.js +0 -949
- package/dist/providers/anthropic.js.map +0 -1
- package/dist/providers/azure-openai-responses.d.ts +0 -15
- package/dist/providers/azure-openai-responses.d.ts.map +0 -1
- package/dist/providers/azure-openai-responses.js +0 -225
- package/dist/providers/azure-openai-responses.js.map +0 -1
- package/dist/providers/cloudflare.d.ts +0 -13
- package/dist/providers/cloudflare.d.ts.map +0 -1
- package/dist/providers/cloudflare.js +0 -26
- package/dist/providers/cloudflare.js.map +0 -1
- package/dist/providers/faux.d.ts +0 -56
- package/dist/providers/faux.d.ts.map +0 -1
- package/dist/providers/faux.js +0 -368
- package/dist/providers/faux.js.map +0 -1
- package/dist/providers/github-copilot-headers.d.ts +0 -8
- package/dist/providers/github-copilot-headers.d.ts.map +0 -1
- package/dist/providers/github-copilot-headers.js +0 -29
- package/dist/providers/github-copilot-headers.js.map +0 -1
- package/dist/providers/google-gemini-cli.d.ts +0 -74
- package/dist/providers/google-gemini-cli.d.ts.map +0 -1
- package/dist/providers/google-gemini-cli.js +0 -779
- package/dist/providers/google-gemini-cli.js.map +0 -1
- package/dist/providers/google-shared.d.ts +0 -70
- package/dist/providers/google-shared.d.ts.map +0 -1
- package/dist/providers/google-shared.js +0 -329
- package/dist/providers/google-shared.js.map +0 -1
- package/dist/providers/google-vertex.d.ts +0 -15
- package/dist/providers/google-vertex.d.ts.map +0 -1
- package/dist/providers/google-vertex.js +0 -442
- package/dist/providers/google-vertex.js.map +0 -1
- package/dist/providers/google.d.ts +0 -13
- package/dist/providers/google.d.ts.map +0 -1
- package/dist/providers/google.js +0 -400
- package/dist/providers/google.js.map +0 -1
- package/dist/providers/images/openrouter.d.ts +0 -3
- package/dist/providers/images/openrouter.d.ts.map +0 -1
- package/dist/providers/images/openrouter.js +0 -129
- package/dist/providers/images/openrouter.js.map +0 -1
- package/dist/providers/images/register-builtins.d.ts +0 -4
- package/dist/providers/images/register-builtins.d.ts.map +0 -1
- package/dist/providers/images/register-builtins.js +0 -34
- package/dist/providers/images/register-builtins.js.map +0 -1
- package/dist/providers/mistral.d.ts +0 -25
- package/dist/providers/mistral.d.ts.map +0 -1
- package/dist/providers/mistral.js +0 -535
- package/dist/providers/mistral.js.map +0 -1
- package/dist/providers/openai-codex-responses.d.ts +0 -30
- package/dist/providers/openai-codex-responses.d.ts.map +0 -1
- package/dist/providers/openai-codex-responses.js +0 -1090
- package/dist/providers/openai-codex-responses.js.map +0 -1
- package/dist/providers/openai-completions.d.ts +0 -19
- package/dist/providers/openai-completions.d.ts.map +0 -1
- package/dist/providers/openai-completions.js +0 -950
- package/dist/providers/openai-completions.js.map +0 -1
- package/dist/providers/openai-prompt-cache.d.ts +0 -3
- package/dist/providers/openai-prompt-cache.d.ts.map +0 -1
- package/dist/providers/openai-prompt-cache.js +0 -10
- package/dist/providers/openai-prompt-cache.js.map +0 -1
- package/dist/providers/openai-responses-shared.d.ts +0 -18
- package/dist/providers/openai-responses-shared.d.ts.map +0 -1
- package/dist/providers/openai-responses-shared.js +0 -492
- package/dist/providers/openai-responses-shared.js.map +0 -1
- package/dist/providers/openai-responses.d.ts +0 -13
- package/dist/providers/openai-responses.d.ts.map +0 -1
- package/dist/providers/openai-responses.js +0 -237
- package/dist/providers/openai-responses.js.map +0 -1
- package/dist/providers/register-builtins.d.ts +0 -38
- package/dist/providers/register-builtins.d.ts.map +0 -1
- package/dist/providers/register-builtins.js +0 -278
- package/dist/providers/register-builtins.js.map +0 -1
- package/dist/providers/simple-options.d.ts +0 -8
- package/dist/providers/simple-options.d.ts.map +0 -1
- package/dist/providers/simple-options.js +0 -41
- package/dist/providers/simple-options.js.map +0 -1
- package/dist/providers/transform-messages.d.ts +0 -8
- package/dist/providers/transform-messages.d.ts.map +0 -1
- package/dist/providers/transform-messages.js +0 -184
- package/dist/providers/transform-messages.js.map +0 -1
- package/dist/session-resources.d.ts +0 -4
- package/dist/session-resources.d.ts.map +0 -1
- package/dist/session-resources.js +0 -22
- package/dist/session-resources.js.map +0 -1
- package/dist/stream.d.ts +0 -8
- package/dist/stream.d.ts.map +0 -1
- package/dist/stream.js +0 -27
- package/dist/stream.js.map +0 -1
- package/dist/types.d.ts +0 -498
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/utils/diagnostics.d.ts +0 -19
- package/dist/utils/diagnostics.d.ts.map +0 -1
- package/dist/utils/diagnostics.js +0 -25
- package/dist/utils/diagnostics.js.map +0 -1
- package/dist/utils/event-stream.d.ts +0 -21
- package/dist/utils/event-stream.d.ts.map +0 -1
- package/dist/utils/event-stream.js +0 -81
- package/dist/utils/event-stream.js.map +0 -1
- package/dist/utils/hash.d.ts +0 -3
- package/dist/utils/hash.d.ts.map +0 -1
- package/dist/utils/hash.js +0 -14
- package/dist/utils/hash.js.map +0 -1
- package/dist/utils/headers.d.ts +0 -2
- package/dist/utils/headers.d.ts.map +0 -1
- package/dist/utils/headers.js +0 -8
- package/dist/utils/headers.js.map +0 -1
- package/dist/utils/json-parse.d.ts +0 -16
- package/dist/utils/json-parse.d.ts.map +0 -1
- package/dist/utils/json-parse.js +0 -113
- package/dist/utils/json-parse.js.map +0 -1
- package/dist/utils/node-http-proxy.d.ts +0 -10
- package/dist/utils/node-http-proxy.d.ts.map +0 -1
- package/dist/utils/node-http-proxy.js +0 -97
- package/dist/utils/node-http-proxy.js.map +0 -1
- package/dist/utils/oauth/anthropic.d.ts +0 -25
- package/dist/utils/oauth/anthropic.d.ts.map +0 -1
- package/dist/utils/oauth/anthropic.js +0 -335
- package/dist/utils/oauth/anthropic.js.map +0 -1
- package/dist/utils/oauth/device-code.d.ts +0 -19
- package/dist/utils/oauth/device-code.d.ts.map +0 -1
- package/dist/utils/oauth/device-code.js +0 -55
- package/dist/utils/oauth/device-code.js.map +0 -1
- package/dist/utils/oauth/github-copilot.d.ts +0 -30
- package/dist/utils/oauth/github-copilot.d.ts.map +0 -1
- package/dist/utils/oauth/github-copilot.js +0 -268
- package/dist/utils/oauth/github-copilot.js.map +0 -1
- package/dist/utils/oauth/google-antigravity.d.ts +0 -26
- package/dist/utils/oauth/google-antigravity.d.ts.map +0 -1
- package/dist/utils/oauth/google-antigravity.js +0 -377
- package/dist/utils/oauth/google-antigravity.js.map +0 -1
- package/dist/utils/oauth/google-gemini-cli.d.ts +0 -26
- package/dist/utils/oauth/google-gemini-cli.d.ts.map +0 -1
- package/dist/utils/oauth/google-gemini-cli.js +0 -482
- package/dist/utils/oauth/google-gemini-cli.js.map +0 -1
- package/dist/utils/oauth/index.d.ts +0 -63
- package/dist/utils/oauth/index.d.ts.map +0 -1
- package/dist/utils/oauth/index.js +0 -131
- package/dist/utils/oauth/index.js.map +0 -1
- package/dist/utils/oauth/oauth-page.d.ts +0 -3
- package/dist/utils/oauth/oauth-page.d.ts.map +0 -1
- package/dist/utils/oauth/oauth-page.js +0 -105
- package/dist/utils/oauth/oauth-page.js.map +0 -1
- package/dist/utils/oauth/openai-codex.d.ts +0 -34
- package/dist/utils/oauth/openai-codex.d.ts.map +0 -1
- package/dist/utils/oauth/openai-codex.js +0 -385
- package/dist/utils/oauth/openai-codex.js.map +0 -1
- package/dist/utils/oauth/pkce.d.ts +0 -13
- package/dist/utils/oauth/pkce.d.ts.map +0 -1
- package/dist/utils/oauth/pkce.js +0 -31
- package/dist/utils/oauth/pkce.js.map +0 -1
- package/dist/utils/oauth/types.d.ts +0 -64
- package/dist/utils/oauth/types.d.ts.map +0 -1
- package/dist/utils/oauth/types.js +0 -2
- package/dist/utils/oauth/types.js.map +0 -1
- package/dist/utils/overflow.d.ts +0 -56
- package/dist/utils/overflow.d.ts.map +0 -1
- package/dist/utils/overflow.js +0 -151
- package/dist/utils/overflow.js.map +0 -1
- package/dist/utils/sanitize-unicode.d.ts +0 -22
- package/dist/utils/sanitize-unicode.d.ts.map +0 -1
- package/dist/utils/sanitize-unicode.js +0 -26
- package/dist/utils/sanitize-unicode.js.map +0 -1
- package/dist/utils/typebox-helpers.d.ts +0 -17
- package/dist/utils/typebox-helpers.d.ts.map +0 -1
- package/dist/utils/typebox-helpers.js +0 -21
- package/dist/utils/typebox-helpers.js.map +0 -1
- package/dist/utils/validation.d.ts +0 -18
- package/dist/utils/validation.d.ts.map +0 -1
- package/dist/utils/validation.js +0 -281
- package/dist/utils/validation.js.map +0 -1
|
@@ -0,0 +1,3018 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import { scheduler } from "node:timers/promises";
|
|
3
|
+
import {
|
|
4
|
+
$env,
|
|
5
|
+
$flag,
|
|
6
|
+
asRecord,
|
|
7
|
+
extractHttpStatusFromError,
|
|
8
|
+
fetchWithRetry,
|
|
9
|
+
logger,
|
|
10
|
+
readSseJson,
|
|
11
|
+
structuredCloneJSON,
|
|
12
|
+
} from "@aryee337/aery-utils";
|
|
13
|
+
import type OpenAI from "openai";
|
|
14
|
+
import type {
|
|
15
|
+
ResponseCustomToolCall,
|
|
16
|
+
ResponseFunctionToolCall,
|
|
17
|
+
ResponseInput,
|
|
18
|
+
ResponseInputContent,
|
|
19
|
+
ResponseOutputMessage,
|
|
20
|
+
ResponseReasoningItem,
|
|
21
|
+
} from "openai/resources/responses/responses";
|
|
22
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
23
|
+
import { calculateCost } from "../models";
|
|
24
|
+
import { getEnvApiKey } from "../stream";
|
|
25
|
+
import {
|
|
26
|
+
type Api,
|
|
27
|
+
type AssistantMessage,
|
|
28
|
+
type Context,
|
|
29
|
+
type FetchImpl,
|
|
30
|
+
type Model,
|
|
31
|
+
type ProviderSessionState,
|
|
32
|
+
type RawSseEvent,
|
|
33
|
+
resolveServiceTier,
|
|
34
|
+
type ServiceTier,
|
|
35
|
+
type StreamFunction,
|
|
36
|
+
type StreamOptions,
|
|
37
|
+
type TextContent,
|
|
38
|
+
type ThinkingContent,
|
|
39
|
+
type Tool,
|
|
40
|
+
type ToolCall,
|
|
41
|
+
type ToolChoice,
|
|
42
|
+
} from "../types";
|
|
43
|
+
import {
|
|
44
|
+
createOpenAIResponsesHistoryPayload,
|
|
45
|
+
getOpenAIResponsesHistoryItems,
|
|
46
|
+
getOpenAIResponsesHistoryPayload,
|
|
47
|
+
normalizeSystemPrompts,
|
|
48
|
+
} from "../utils";
|
|
49
|
+
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
50
|
+
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
|
51
|
+
import {
|
|
52
|
+
getOpenAIStreamFirstEventTimeoutMs,
|
|
53
|
+
getOpenAIStreamIdleTimeoutMs,
|
|
54
|
+
iterateWithIdleTimeout,
|
|
55
|
+
} from "../utils/idle-iterator";
|
|
56
|
+
import { parseStreamingJson, parseStreamingJsonThrottled } from "../utils/json-parse";
|
|
57
|
+
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
|
|
58
|
+
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
|
|
59
|
+
import { notifyRawSseEvent } from "../utils/sse-debug";
|
|
60
|
+
import { compactGrammarDefinition } from "./grammar";
|
|
61
|
+
import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
|
|
62
|
+
import {
|
|
63
|
+
type CodexRequestOptions,
|
|
64
|
+
type InputItem,
|
|
65
|
+
type RequestBody,
|
|
66
|
+
transformRequestBody,
|
|
67
|
+
} from "./openai-codex/request-transformer";
|
|
68
|
+
import { parseCodexError } from "./openai-codex/response-handler";
|
|
69
|
+
import { normalizeOpenAIResponsesPromptCacheKey } from "./openai-responses";
|
|
70
|
+
import {
|
|
71
|
+
appendResponsesToolResultMessages,
|
|
72
|
+
convertResponsesAssistantMessage,
|
|
73
|
+
convertResponsesInputContent,
|
|
74
|
+
encodeResponsesToolCallId,
|
|
75
|
+
encodeTextSignatureV1,
|
|
76
|
+
isOpenAIResponsesProgressEvent,
|
|
77
|
+
mapOpenAIResponsesStopReason,
|
|
78
|
+
populateResponsesUsageFromResponse,
|
|
79
|
+
} from "./openai-responses-shared";
|
|
80
|
+
import { transformMessages } from "./transform-messages";
|
|
81
|
+
|
|
82
|
+
export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
83
|
+
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
84
|
+
reasoningSummary?: "auto" | "concise" | "detailed" | null;
|
|
85
|
+
textVerbosity?: "low" | "medium" | "high";
|
|
86
|
+
include?: string[];
|
|
87
|
+
codexMode?: boolean;
|
|
88
|
+
toolChoice?: ToolChoice;
|
|
89
|
+
preferWebsockets?: boolean;
|
|
90
|
+
serviceTier?: ServiceTier;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const CODEX_DEBUG = $flag("PI_CODEX_DEBUG");
|
|
94
|
+
const CODEX_MAX_RETRIES = 5;
|
|
95
|
+
const CODEX_RETRY_DELAY_MS = 500;
|
|
96
|
+
const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
|
|
97
|
+
const CODEX_WEBSOCKET_PING_INTERVAL_MS = 10_000;
|
|
98
|
+
const CODEX_WEBSOCKET_PONG_TIMEOUT_MS = 60_000;
|
|
99
|
+
const CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY = 4096;
|
|
100
|
+
/**
|
|
101
|
+
* Maximum quiet period (no inbound frames AND no observed pong) we'll trust a
|
|
102
|
+
* reused WebSocket for before forcing a fresh handshake. Codex backends and
|
|
103
|
+
* intermediaries occasionally evict idle sockets server-side without sending a
|
|
104
|
+
* FIN, leaving the local `readyState` as OPEN while the next `send()` becomes a
|
|
105
|
+
* write into a half-open buffer. Reusing such a socket parks the next request
|
|
106
|
+
* at `#nextMessage` until the first-event/idle timeout fires (issue #1450). The
|
|
107
|
+
* heartbeat below also catches dead sockets, but only after `pongTimeoutMs`
|
|
108
|
+
* (default 60s) and only while a request is active — this gate closes the door
|
|
109
|
+
* earlier and even when the gap between requests is purely client-side (tool
|
|
110
|
+
* execution, user typing, etc.). Set `PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS=0`
|
|
111
|
+
* to disable.
|
|
112
|
+
*/
|
|
113
|
+
const CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = 30_000;
|
|
114
|
+
/**
|
|
115
|
+
* Steady-state liveness ceiling for the Codex WebSocket transport. Distinct from
|
|
116
|
+
* the AERY-wide stream watchdog removed in #1392: a WebSocket can stay TCP-open
|
|
117
|
+
* indefinitely without exchanging frames (server crash after upgrade, half-open
|
|
118
|
+
* network path), so we still need a transport-internal cap to detect those
|
|
119
|
+
* states and trigger the WS→SSE fallback. Only applies AFTER the first event
|
|
120
|
+
* has arrived — slow first-token paths wait as long as the caller permits.
|
|
121
|
+
*/
|
|
122
|
+
const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300_000;
|
|
123
|
+
/**
|
|
124
|
+
* Maximum wait for the first WebSocket event before falling back to SSE.
|
|
125
|
+
* Unlike a stream watchdog, this triggers a transport switch (not a request
|
|
126
|
+
* failure) — the outer retry loop catches the timeout error and re-runs on
|
|
127
|
+
* SSE. Generous default so legitimately slow first-token providers still get
|
|
128
|
+
* a chance on the WS transport before falling through.
|
|
129
|
+
*/
|
|
130
|
+
const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = 60_000;
|
|
131
|
+
const CODEX_WEBSOCKET_RETRY_BUDGET = CODEX_MAX_RETRIES;
|
|
132
|
+
const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
|
|
133
|
+
const CODEX_RETRYABLE_EVENT_CODES = new Set(["model_error", "server_error", "internal_error"]);
|
|
134
|
+
const CODEX_RETRYABLE_EVENT_MESSAGE =
|
|
135
|
+
/processing your request|retry your request|temporar(?:y|ily)|overloaded|service.?unavailable|internal error|server error/i;
|
|
136
|
+
const CODEX_PROVIDER_SESSION_STATE_KEY = "openai-codex-responses";
|
|
137
|
+
const X_CODEX_TURN_STATE_HEADER = "x-codex-turn-state";
|
|
138
|
+
const X_MODELS_ETAG_HEADER = "x-models-etag";
|
|
139
|
+
const X_REASONING_INCLUDED_HEADER = "x-reasoning-included";
|
|
140
|
+
/** Connection-level websocket failures that should immediately fall back to SSE without retrying. */
|
|
141
|
+
const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed before open", "connection timeout"];
|
|
142
|
+
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
|
|
143
|
+
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
|
144
|
+
const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]);
|
|
145
|
+
|
|
146
|
+
function isCodexStreamProgressEvent(event: unknown): boolean {
|
|
147
|
+
if (isOpenAIResponsesProgressEvent(event)) return true;
|
|
148
|
+
if (!event || typeof event !== "object") return false;
|
|
149
|
+
const type = (event as { type?: unknown }).type;
|
|
150
|
+
return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type CodexWebSocketTimeoutDetails = {
|
|
154
|
+
lastEventAt: number;
|
|
155
|
+
lastEventType?: string;
|
|
156
|
+
lastProgressAt: number;
|
|
157
|
+
lastProgressEventType?: string;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function createCodexWebSocketTimeoutMessage(reason: string, details: CodexWebSocketTimeoutDetails): string {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
const lastEvent = details.lastEventType
|
|
163
|
+
? `${details.lastEventType} ${Math.max(0, now - details.lastEventAt)}ms ago`
|
|
164
|
+
: "none";
|
|
165
|
+
const lastProgress = details.lastProgressEventType
|
|
166
|
+
? `${details.lastProgressEventType} ${Math.max(0, now - details.lastProgressAt)}ms ago`
|
|
167
|
+
: "none";
|
|
168
|
+
return `${reason} (last event: ${lastEvent}; last progress: ${lastProgress})`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type CodexTransport = "sse" | "websocket";
|
|
172
|
+
type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
|
|
173
|
+
type CodexOutputBlock = ThinkingContent | TextContent | (ToolCall & { partialJson: string; lastParseLen?: number });
|
|
174
|
+
|
|
175
|
+
export interface OpenAICodexWebSocketDebugStats {
|
|
176
|
+
fullContextRequests: number;
|
|
177
|
+
deltaRequests: number;
|
|
178
|
+
lastInputItems: number;
|
|
179
|
+
lastDeltaInputItems?: number;
|
|
180
|
+
lastPreviousResponseId?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type CodexWebSocketSessionState = {
|
|
184
|
+
disableWebsocket: boolean;
|
|
185
|
+
lastRequest?: RequestBody;
|
|
186
|
+
lastResponseId?: string;
|
|
187
|
+
lastResponseItems?: InputItem[];
|
|
188
|
+
canAppend: boolean;
|
|
189
|
+
turnState?: string;
|
|
190
|
+
modelsEtag?: string;
|
|
191
|
+
reasoningIncluded?: boolean;
|
|
192
|
+
connection?: CodexWebSocketConnection;
|
|
193
|
+
lastTransport?: CodexTransport;
|
|
194
|
+
fallbackCount: number;
|
|
195
|
+
lastFallbackAt?: number;
|
|
196
|
+
prewarmed: boolean;
|
|
197
|
+
stats: OpenAICodexWebSocketDebugStats;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
interface CodexProviderSessionState extends ProviderSessionState {
|
|
201
|
+
webSocketSessions: Map<string, CodexWebSocketSessionState>;
|
|
202
|
+
webSocketPublicToPrivate: Map<string, string>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface CodexRequestContext {
|
|
206
|
+
apiKey: string;
|
|
207
|
+
accountId: string;
|
|
208
|
+
baseUrl: string;
|
|
209
|
+
url: string;
|
|
210
|
+
requestHeaders: Record<string, string>;
|
|
211
|
+
transportSessionId?: string;
|
|
212
|
+
providerSessionState?: CodexProviderSessionState;
|
|
213
|
+
websocketState?: CodexWebSocketSessionState;
|
|
214
|
+
transformedBody: RequestBody;
|
|
215
|
+
rawRequestDump: RawHttpRequestDump;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface CodexRequestSetup {
|
|
219
|
+
requestSignal: AbortSignal;
|
|
220
|
+
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
|
|
221
|
+
requestAbortController: AbortController;
|
|
222
|
+
websocketIdleTimeoutMs: number | undefined;
|
|
223
|
+
websocketFirstEventTimeoutMs: number | undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface CodexStreamRuntime {
|
|
227
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
228
|
+
requestBodyForState: RequestBody;
|
|
229
|
+
transport: CodexTransport;
|
|
230
|
+
websocketState?: CodexWebSocketSessionState;
|
|
231
|
+
currentItem: CodexEventItem | null;
|
|
232
|
+
currentBlock: CodexOutputBlock | null;
|
|
233
|
+
nativeOutputItems: Array<Record<string, unknown>>;
|
|
234
|
+
websocketStreamRetries: number;
|
|
235
|
+
providerRetryAttempt: number;
|
|
236
|
+
sawTerminalEvent: boolean;
|
|
237
|
+
canSafelyReplayWebsocketOverSse: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface CodexStreamProcessingContext {
|
|
241
|
+
model: Model<"openai-codex-responses">;
|
|
242
|
+
output: AssistantMessage;
|
|
243
|
+
stream: AssistantMessageEventStream;
|
|
244
|
+
options: OpenAICodexResponsesOptions | undefined;
|
|
245
|
+
requestSetup: CodexRequestSetup;
|
|
246
|
+
requestContext: CodexRequestContext;
|
|
247
|
+
startTime: number;
|
|
248
|
+
firstTokenTime?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface CodexStreamCompletion {
|
|
252
|
+
firstTokenTime?: number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseCodexNonNegativeInteger(value: string | undefined, fallback: number): number {
|
|
256
|
+
if (!value) return fallback;
|
|
257
|
+
const parsed = Number(value);
|
|
258
|
+
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
|
259
|
+
return Math.trunc(parsed);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseCodexPositiveInteger(value: string | undefined, fallback: number): number {
|
|
263
|
+
if (!value) return fallback;
|
|
264
|
+
const parsed = Number(value);
|
|
265
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
266
|
+
return Math.trunc(parsed);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isCodexWebSocketEnvEnabled(): boolean {
|
|
270
|
+
return $flag("PI_CODEX_WEBSOCKET");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getCodexWebSocketRetryBudget(): number {
|
|
274
|
+
return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getCodexWebSocketRetryDelayMs(retry: number): number {
|
|
278
|
+
const baseDelay = parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS, CODEX_RETRY_DELAY_MS);
|
|
279
|
+
return baseDelay * Math.max(1, retry);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function getCodexWebSocketIdleTimeoutMs(): number {
|
|
283
|
+
return parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS, CODEX_WEBSOCKET_IDLE_TIMEOUT_MS);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getCodexWebSocketFirstEventTimeoutMs(): number {
|
|
287
|
+
return parseCodexPositiveInteger(
|
|
288
|
+
$env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS,
|
|
289
|
+
CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getCodexWebSocketPingIntervalMs(): number {
|
|
294
|
+
return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS, CODEX_WEBSOCKET_PING_INTERVAL_MS);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getCodexWebSocketPongTimeoutMs(): number {
|
|
298
|
+
return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS, CODEX_WEBSOCKET_PONG_TIMEOUT_MS);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function getCodexWebSocketMessageQueueCapacity(): number {
|
|
302
|
+
return parseCodexPositiveInteger(
|
|
303
|
+
$env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY,
|
|
304
|
+
CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function getCodexWebSocketMaxIdleReuseMs(): number {
|
|
309
|
+
return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS, CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function createCodexProviderSessionState(): CodexProviderSessionState {
|
|
313
|
+
const state: CodexProviderSessionState = {
|
|
314
|
+
webSocketSessions: new Map(),
|
|
315
|
+
webSocketPublicToPrivate: new Map(),
|
|
316
|
+
close: () => {
|
|
317
|
+
for (const session of state.webSocketSessions.values()) {
|
|
318
|
+
session.connection?.close("session_disposed");
|
|
319
|
+
}
|
|
320
|
+
state.webSocketSessions.clear();
|
|
321
|
+
state.webSocketPublicToPrivate.clear();
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
return state;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function getCodexProviderSessionState(
|
|
328
|
+
providerSessionState: Map<string, ProviderSessionState> | undefined,
|
|
329
|
+
): CodexProviderSessionState | undefined {
|
|
330
|
+
if (!providerSessionState) return undefined;
|
|
331
|
+
const existing = providerSessionState.get(CODEX_PROVIDER_SESSION_STATE_KEY) as CodexProviderSessionState | undefined;
|
|
332
|
+
if (existing) return existing;
|
|
333
|
+
const created = createCodexProviderSessionState();
|
|
334
|
+
providerSessionState.set(CODEX_PROVIDER_SESSION_STATE_KEY, created);
|
|
335
|
+
return created;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function createCodexWebSocketTransportError(message: string): Error {
|
|
339
|
+
return new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isCodexWebSocketFatalError(error: Error): boolean {
|
|
343
|
+
const msg = error.message.toLowerCase();
|
|
344
|
+
return CODEX_WEBSOCKET_FATAL_PATTERNS.some(pattern => msg.includes(pattern.toLowerCase()));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isCodexWebSocketTransportError(error: unknown): boolean {
|
|
348
|
+
if (!(error instanceof Error)) return false;
|
|
349
|
+
return error.message.startsWith(CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isCodexWebSocketRetryableStreamError(error: unknown): boolean {
|
|
353
|
+
if (!(error instanceof Error) || !isCodexWebSocketTransportError(error)) return false;
|
|
354
|
+
const message = error.message.toLowerCase();
|
|
355
|
+
return (
|
|
356
|
+
message.includes("websocket closed (") ||
|
|
357
|
+
message.includes("websocket closed before response completion") ||
|
|
358
|
+
message.includes("websocket connection is unavailable") ||
|
|
359
|
+
message.includes("websocket send failed") ||
|
|
360
|
+
message.includes("websocket ping failed") ||
|
|
361
|
+
message.includes("websocket pong timeout") ||
|
|
362
|
+
message.includes("websocket message queue exceeded") ||
|
|
363
|
+
message.includes("idle timeout waiting for websocket") ||
|
|
364
|
+
message.includes("timeout waiting for first websocket event") ||
|
|
365
|
+
message.includes("syntaxerror") ||
|
|
366
|
+
message.includes("json")
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function toCodexHeaderRecord(value: unknown): Record<string, string> | null {
|
|
371
|
+
if (!value || typeof value !== "object") return null;
|
|
372
|
+
const headers: Record<string, string> = {};
|
|
373
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
374
|
+
if (typeof entry === "string") {
|
|
375
|
+
headers[key] = entry;
|
|
376
|
+
} else if (Array.isArray(entry) && entry.every(item => typeof item === "string")) {
|
|
377
|
+
headers[key] = entry.join(",");
|
|
378
|
+
} else if (typeof entry === "number" || typeof entry === "boolean") {
|
|
379
|
+
headers[key] = String(entry);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return Object.keys(headers).length > 0 ? headers : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function toCodexHeaders(value: unknown): Headers | undefined {
|
|
386
|
+
if (!value) return undefined;
|
|
387
|
+
if (value instanceof Headers) return value;
|
|
388
|
+
if (Array.isArray(value)) {
|
|
389
|
+
try {
|
|
390
|
+
return new Headers(value as Array<[string, string]>);
|
|
391
|
+
} catch {
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const record = toCodexHeaderRecord(value);
|
|
396
|
+
if (!record) return undefined;
|
|
397
|
+
return new Headers(record);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function updateCodexSessionMetadataFromHeaders(
|
|
401
|
+
state: CodexWebSocketSessionState | undefined,
|
|
402
|
+
headers: Headers | Record<string, string> | null | undefined,
|
|
403
|
+
): void {
|
|
404
|
+
if (!state || !headers) return;
|
|
405
|
+
const resolvedHeaders = headers instanceof Headers ? headers : new Headers(headers);
|
|
406
|
+
const turnState = resolvedHeaders.get(X_CODEX_TURN_STATE_HEADER);
|
|
407
|
+
if (turnState && turnState.length > 0) {
|
|
408
|
+
state.turnState = turnState;
|
|
409
|
+
}
|
|
410
|
+
const modelsEtag = resolvedHeaders.get(X_MODELS_ETAG_HEADER);
|
|
411
|
+
if (modelsEtag && modelsEtag.length > 0) {
|
|
412
|
+
state.modelsEtag = modelsEtag;
|
|
413
|
+
}
|
|
414
|
+
const reasoningIncluded = resolvedHeaders.get(X_REASONING_INCLUDED_HEADER);
|
|
415
|
+
if (reasoningIncluded !== null) {
|
|
416
|
+
const normalized = reasoningIncluded.trim().toLowerCase();
|
|
417
|
+
state.reasoningIncluded = normalized.length === 0 ? true : normalized !== "false";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function extractCodexWebSocketHandshakeHeaders(socket: Bun.WebSocket, openEvent?: Event): Headers | undefined {
|
|
422
|
+
const eventRecord = openEvent as Record<string, unknown> | undefined;
|
|
423
|
+
const eventResponse = eventRecord?.response as Record<string, unknown> | undefined;
|
|
424
|
+
const socketRecord = socket as unknown as Record<string, unknown>;
|
|
425
|
+
const socketResponse = socketRecord.response as Record<string, unknown> | undefined;
|
|
426
|
+
const socketHandshake = socketRecord.handshake as Record<string, unknown> | undefined;
|
|
427
|
+
return (
|
|
428
|
+
toCodexHeaders(eventRecord?.responseHeaders) ??
|
|
429
|
+
toCodexHeaders(eventRecord?.headers) ??
|
|
430
|
+
toCodexHeaders(eventResponse?.headers) ??
|
|
431
|
+
toCodexHeaders(socketRecord.responseHeaders) ??
|
|
432
|
+
toCodexHeaders(socketRecord.handshakeHeaders) ??
|
|
433
|
+
toCodexHeaders(socketResponse?.headers) ??
|
|
434
|
+
toCodexHeaders(socketHandshake?.headers)
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Synthesizes a `RawSseEvent` for a Codex WebSocket frame so the same debug
|
|
439
|
+
// pipeline used for HTTP SSE (`onSseEvent` → `RawSseDebugBuffer.recordEvent`)
|
|
440
|
+
// also captures WebSocket traffic. The `raw` array mirrors SSE wire format
|
|
441
|
+
// (one line per field) so the existing TUI viewer renders it identically:
|
|
442
|
+
// : ws ← <type>
|
|
443
|
+
// event: <type>
|
|
444
|
+
// data: <json>
|
|
445
|
+
// Outbound (client → server) uses `: ws → <type>`. The viewer pretty-prints
|
|
446
|
+
// `data:` JSON lines, so we keep the wire JSON single-line here and let the
|
|
447
|
+
// renderer expand it.
|
|
448
|
+
function notifyCodexWebSocketInbound(
|
|
449
|
+
observer: ((event: RawSseEvent) => void) | undefined,
|
|
450
|
+
parsed: Record<string, unknown>,
|
|
451
|
+
text: string,
|
|
452
|
+
): void {
|
|
453
|
+
const type = typeof parsed.type === "string" ? parsed.type : null;
|
|
454
|
+
const raw: string[] = [`: ws ← ${type ?? "(untyped)"}`];
|
|
455
|
+
if (type) raw.push(`event: ${type}`);
|
|
456
|
+
raw.push(`data: ${text}`);
|
|
457
|
+
notifyRawSseEvent(observer, { event: type, data: text, raw });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function notifyCodexWebSocketOutbound(
|
|
461
|
+
observer: ((event: RawSseEvent) => void) | undefined,
|
|
462
|
+
request: Record<string, unknown>,
|
|
463
|
+
payload: string,
|
|
464
|
+
): void {
|
|
465
|
+
const type = typeof request.type === "string" ? request.type : null;
|
|
466
|
+
const raw: string[] = [`: ws → ${type ?? "(untyped)"}`];
|
|
467
|
+
if (type) raw.push(`event: ${type}`);
|
|
468
|
+
raw.push(`data: ${payload}`);
|
|
469
|
+
notifyRawSseEvent(observer, { event: type, data: payload, raw });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function notifyCodexWebSocketMalformed(
|
|
473
|
+
observer: ((event: RawSseEvent) => void) | undefined,
|
|
474
|
+
data: unknown,
|
|
475
|
+
error: unknown,
|
|
476
|
+
): void {
|
|
477
|
+
const text = typeof data === "string" ? data : "";
|
|
478
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
479
|
+
const raw: string[] = [`: ws ← (parse-error: ${reason})`];
|
|
480
|
+
if (text) raw.push(`data: ${text}`);
|
|
481
|
+
notifyRawSseEvent(observer, { event: "parse_error", data: text, raw });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** @internal Exported for tests. */
|
|
485
|
+
export function normalizeCodexToolChoice(
|
|
486
|
+
choice: ToolChoice | undefined,
|
|
487
|
+
tools: Tool[] = [],
|
|
488
|
+
model?: Model<"openai-codex-responses">,
|
|
489
|
+
): string | Record<string, unknown> | undefined {
|
|
490
|
+
if (!choice) return undefined;
|
|
491
|
+
if (typeof choice === "string") return choice;
|
|
492
|
+
const allowFreeform = model ? supportsFreeformApplyPatchCodex(model) : false;
|
|
493
|
+
const mapName = (name: string): Record<string, string> => {
|
|
494
|
+
const customTool = allowFreeform
|
|
495
|
+
? tools.find(tool => tool.customFormat && (tool.name === name || tool.customWireName === name))
|
|
496
|
+
: undefined;
|
|
497
|
+
return customTool
|
|
498
|
+
? { type: "custom", name: customTool.customWireName ?? customTool.name }
|
|
499
|
+
: { type: "function", name };
|
|
500
|
+
};
|
|
501
|
+
if (choice.type === "function") {
|
|
502
|
+
if ("function" in choice && choice.function?.name) {
|
|
503
|
+
return mapName(choice.function.name);
|
|
504
|
+
}
|
|
505
|
+
if ("name" in choice && choice.name) {
|
|
506
|
+
return mapName(choice.name);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (choice.type === "tool" && choice.name) {
|
|
510
|
+
return mapName(choice.name);
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function createEmptyUsage(): AssistantMessage["usage"] {
|
|
516
|
+
return {
|
|
517
|
+
input: 0,
|
|
518
|
+
output: 0,
|
|
519
|
+
cacheRead: 0,
|
|
520
|
+
cacheWrite: 0,
|
|
521
|
+
totalTokens: 0,
|
|
522
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function getCodexUserAgent(): string {
|
|
527
|
+
return `aery/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function getCodexServiceTierCostMultiplier(
|
|
531
|
+
model: Pick<Model<"openai-codex-responses">, "id">,
|
|
532
|
+
serviceTier: ServiceTier | "default" | undefined,
|
|
533
|
+
): number {
|
|
534
|
+
switch (serviceTier) {
|
|
535
|
+
case "flex":
|
|
536
|
+
return 0.5;
|
|
537
|
+
case "priority":
|
|
538
|
+
return model.id === "gpt-5.5" ? 2.5 : 2;
|
|
539
|
+
default:
|
|
540
|
+
return 1;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function resolveCodexCostServiceTier(res: unknown, req?: unknown): ServiceTier | "default" | undefined {
|
|
545
|
+
switch (res) {
|
|
546
|
+
case "flex":
|
|
547
|
+
return "flex";
|
|
548
|
+
case "priority":
|
|
549
|
+
return "priority";
|
|
550
|
+
default:
|
|
551
|
+
if (req === "flex" || req === "priority") {
|
|
552
|
+
return req;
|
|
553
|
+
}
|
|
554
|
+
return "default";
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function applyCodexServiceTierPricing(
|
|
559
|
+
model: Pick<Model<"openai-codex-responses">, "id">,
|
|
560
|
+
usage: AssistantMessage["usage"],
|
|
561
|
+
resTier: unknown,
|
|
562
|
+
reqTier: unknown,
|
|
563
|
+
): void {
|
|
564
|
+
const resolvedTier = resolveCodexCostServiceTier(resTier, reqTier);
|
|
565
|
+
const multiplier = getCodexServiceTierCostMultiplier(model, resolvedTier);
|
|
566
|
+
if (multiplier === 1) return;
|
|
567
|
+
usage.cost.input *= multiplier;
|
|
568
|
+
usage.cost.output *= multiplier;
|
|
569
|
+
usage.cost.cacheRead *= multiplier;
|
|
570
|
+
usage.cost.cacheWrite *= multiplier;
|
|
571
|
+
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function createAssistantOutput(model: Model<"openai-codex-responses">): AssistantMessage {
|
|
575
|
+
return {
|
|
576
|
+
role: "assistant",
|
|
577
|
+
content: [],
|
|
578
|
+
api: "openai-codex-responses" as Api,
|
|
579
|
+
provider: model.provider,
|
|
580
|
+
model: model.id,
|
|
581
|
+
usage: createEmptyUsage(),
|
|
582
|
+
stopReason: "stop",
|
|
583
|
+
timestamp: Date.now(),
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function resetOutputState(output: AssistantMessage): void {
|
|
588
|
+
output.content.length = 0;
|
|
589
|
+
output.usage = createEmptyUsage();
|
|
590
|
+
output.stopReason = "stop";
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function removeTransientBlockIndices(output: AssistantMessage): void {
|
|
594
|
+
for (const block of output.content) {
|
|
595
|
+
delete (block as { index?: number }).index;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): CodexRequestSetup {
|
|
600
|
+
const requestAbortController = new AbortController();
|
|
601
|
+
const requestSignal = options?.signal
|
|
602
|
+
? AbortSignal.any([options.signal, requestAbortController.signal])
|
|
603
|
+
: requestAbortController.signal;
|
|
604
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
|
|
605
|
+
const websocketIdleTimeoutMs = options?.streamIdleTimeoutMs ?? getCodexWebSocketIdleTimeoutMs();
|
|
606
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
607
|
+
const websocketFirstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getCodexWebSocketFirstEventTimeoutMs();
|
|
608
|
+
const wrapCodexSseStream = (
|
|
609
|
+
source: AsyncGenerator<Record<string, unknown>>,
|
|
610
|
+
): AsyncGenerator<Record<string, unknown>> =>
|
|
611
|
+
iterateWithIdleTimeout(source, {
|
|
612
|
+
idleTimeoutMs,
|
|
613
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
614
|
+
firstItemErrorMessage: "OpenAI Codex SSE stream timed out while waiting for the first event",
|
|
615
|
+
errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
|
|
616
|
+
onIdle: () => requestAbortController.abort(),
|
|
617
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
618
|
+
abortSignal: options?.signal,
|
|
619
|
+
isProgressItem: isCodexStreamProgressEvent,
|
|
620
|
+
});
|
|
621
|
+
return {
|
|
622
|
+
requestAbortController,
|
|
623
|
+
requestSignal,
|
|
624
|
+
wrapCodexSseStream,
|
|
625
|
+
websocketIdleTimeoutMs,
|
|
626
|
+
websocketFirstEventTimeoutMs,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function buildCodexRequestContext(
|
|
631
|
+
model: Model<"openai-codex-responses">,
|
|
632
|
+
context: Context,
|
|
633
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
634
|
+
output: AssistantMessage,
|
|
635
|
+
): Promise<CodexRequestContext> {
|
|
636
|
+
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
|
637
|
+
if (!apiKey) {
|
|
638
|
+
throw new Error(`No API key for provider: ${model.provider}`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const accountId = getAccountId(apiKey);
|
|
642
|
+
const baseUrl = model.baseUrl || CODEX_BASE_URL;
|
|
643
|
+
const url = resolveCodexResponsesUrl(baseUrl);
|
|
644
|
+
const promptCacheKey = resolveCodexPromptCacheKey(options);
|
|
645
|
+
const transportSessionId = resolveCodexTransportSessionId(options);
|
|
646
|
+
const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
|
|
647
|
+
options?.onPayload?.(transformedBody);
|
|
648
|
+
|
|
649
|
+
const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
|
|
650
|
+
const rawRequestDump: RawHttpRequestDump = {
|
|
651
|
+
provider: model.provider,
|
|
652
|
+
api: output.api,
|
|
653
|
+
model: model.id,
|
|
654
|
+
method: "POST",
|
|
655
|
+
url,
|
|
656
|
+
body: transformedBody,
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
660
|
+
const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl);
|
|
661
|
+
const publicSessionKey = getCodexPublicSessionKey(transportSessionId, model, baseUrl);
|
|
662
|
+
if (sessionKey && publicSessionKey) {
|
|
663
|
+
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
664
|
+
}
|
|
665
|
+
const websocketState =
|
|
666
|
+
sessionKey && providerSessionState ? getCodexWebSocketSessionState(sessionKey, providerSessionState) : undefined;
|
|
667
|
+
return {
|
|
668
|
+
apiKey,
|
|
669
|
+
accountId,
|
|
670
|
+
baseUrl,
|
|
671
|
+
url,
|
|
672
|
+
requestHeaders,
|
|
673
|
+
transportSessionId,
|
|
674
|
+
providerSessionState,
|
|
675
|
+
websocketState,
|
|
676
|
+
transformedBody,
|
|
677
|
+
rawRequestDump,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function buildTransformedCodexRequestBody(
|
|
682
|
+
model: Model<"openai-codex-responses">,
|
|
683
|
+
context: Context,
|
|
684
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
685
|
+
promptCacheKey = resolveCodexPromptCacheKey(options),
|
|
686
|
+
): Promise<RequestBody> {
|
|
687
|
+
const params: RequestBody = {
|
|
688
|
+
model: model.id,
|
|
689
|
+
input: [...convertMessages(model, context)],
|
|
690
|
+
stream: true,
|
|
691
|
+
prompt_cache_key: promptCacheKey,
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
if (options?.maxTokens) {
|
|
695
|
+
params.max_output_tokens = options.maxTokens;
|
|
696
|
+
}
|
|
697
|
+
if (options?.temperature !== undefined) {
|
|
698
|
+
params.temperature = options.temperature;
|
|
699
|
+
}
|
|
700
|
+
if (options?.topP !== undefined) {
|
|
701
|
+
params.top_p = options.topP;
|
|
702
|
+
}
|
|
703
|
+
if (options?.topK !== undefined) {
|
|
704
|
+
params.top_k = options.topK;
|
|
705
|
+
}
|
|
706
|
+
if (options?.minP !== undefined) {
|
|
707
|
+
params.min_p = options.minP;
|
|
708
|
+
}
|
|
709
|
+
if (options?.presencePenalty !== undefined) {
|
|
710
|
+
params.presence_penalty = options.presencePenalty;
|
|
711
|
+
}
|
|
712
|
+
if (options?.repetitionPenalty !== undefined) {
|
|
713
|
+
params.repetition_penalty = options.repetitionPenalty;
|
|
714
|
+
}
|
|
715
|
+
const resolvedServiceTier = resolveServiceTier(options?.serviceTier, model.provider);
|
|
716
|
+
if (resolvedServiceTier === "flex" || resolvedServiceTier === "scale" || resolvedServiceTier === "priority") {
|
|
717
|
+
params.service_tier = resolvedServiceTier;
|
|
718
|
+
}
|
|
719
|
+
if (context.tools && context.tools.length > 0) {
|
|
720
|
+
params.tools = convertOpenAICodexResponsesTools(context.tools, model);
|
|
721
|
+
if (options?.toolChoice) {
|
|
722
|
+
const toolChoice = normalizeCodexToolChoice(options.toolChoice, context.tools, model);
|
|
723
|
+
if (toolChoice) {
|
|
724
|
+
params.tool_choice = toolChoice;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// When a custom-tool is active, force serial tool-calling. OpenAI's
|
|
728
|
+
// `parallel_tool_calls` is request-scoped — disabling it here affects
|
|
729
|
+
// every tool in the turn, not just the custom one. That's coarser
|
|
730
|
+
// than spec §1's "supports_parallel_tool_calls = false" (which
|
|
731
|
+
// strictly targets `apply_patch`), but the platform API offers no
|
|
732
|
+
// per-tool flag.
|
|
733
|
+
const emittedTools = params.tools as CodexToolPayload[];
|
|
734
|
+
if (emittedTools.some(t => t.type === "custom")) {
|
|
735
|
+
params.parallel_tool_calls = false;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
|
740
|
+
if (systemPrompts.length > 0) {
|
|
741
|
+
params.instructions = systemPrompts[0];
|
|
742
|
+
}
|
|
743
|
+
const developerMessages = systemPrompts.slice(1);
|
|
744
|
+
const codexOptions: CodexRequestOptions = {
|
|
745
|
+
reasoningEffort: options?.reasoning,
|
|
746
|
+
reasoningSummary: options?.reasoningSummary ?? "auto",
|
|
747
|
+
textVerbosity: options?.textVerbosity,
|
|
748
|
+
include: options?.include,
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
return transformRequestBody(params, model, codexOptions, { developerMessages });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function openInitialCodexEventStream(
|
|
755
|
+
model: Model<"openai-codex-responses">,
|
|
756
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
757
|
+
requestSetup: CodexRequestSetup,
|
|
758
|
+
requestContext: CodexRequestContext,
|
|
759
|
+
): Promise<{
|
|
760
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
761
|
+
requestBodyForState: RequestBody;
|
|
762
|
+
transport: CodexTransport;
|
|
763
|
+
}> {
|
|
764
|
+
const { transformedBody, websocketState } = requestContext;
|
|
765
|
+
if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) {
|
|
766
|
+
const websocketRetryBudget = getCodexWebSocketRetryBudget();
|
|
767
|
+
let websocketRetries = 0;
|
|
768
|
+
while (true) {
|
|
769
|
+
try {
|
|
770
|
+
return await openCodexWebSocketTransport(
|
|
771
|
+
requestContext,
|
|
772
|
+
requestSetup,
|
|
773
|
+
websocketState,
|
|
774
|
+
websocketRetries,
|
|
775
|
+
options ? event => options.onSseEvent?.(event, model) : undefined,
|
|
776
|
+
);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
const websocketError = error instanceof Error ? error : new Error(String(error));
|
|
779
|
+
const isFatal = isCodexWebSocketFatalError(websocketError);
|
|
780
|
+
const activateFallback = isFatal || websocketRetries >= websocketRetryBudget;
|
|
781
|
+
recordCodexWebSocketFailure(websocketState, activateFallback);
|
|
782
|
+
logCodexDebug("codex websocket fallback", {
|
|
783
|
+
error: websocketError.message,
|
|
784
|
+
retry: websocketRetries,
|
|
785
|
+
retryBudget: websocketRetryBudget,
|
|
786
|
+
activated: activateFallback,
|
|
787
|
+
fatal: isFatal,
|
|
788
|
+
});
|
|
789
|
+
if (!activateFallback) {
|
|
790
|
+
websocketRetries += 1;
|
|
791
|
+
await scheduler.wait(getCodexWebSocketRetryDelayMs(websocketRetries), {
|
|
792
|
+
signal: requestSetup.requestSignal,
|
|
793
|
+
});
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
|
|
801
|
+
}
|
|
802
|
+
async function openCodexWebSocketTransport(
|
|
803
|
+
requestContext: CodexRequestContext,
|
|
804
|
+
requestSetup: CodexRequestSetup,
|
|
805
|
+
websocketState: CodexWebSocketSessionState,
|
|
806
|
+
retry: number,
|
|
807
|
+
onSseEvent?: (event: RawSseEvent) => void,
|
|
808
|
+
): Promise<{
|
|
809
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
810
|
+
requestBodyForState: RequestBody;
|
|
811
|
+
transport: CodexTransport;
|
|
812
|
+
}> {
|
|
813
|
+
const websocketRequest = buildCodexWebSocketRequest(requestContext.transformedBody, websocketState);
|
|
814
|
+
const websocketHeaders = createCodexHeaders(
|
|
815
|
+
requestContext.requestHeaders,
|
|
816
|
+
requestContext.accountId,
|
|
817
|
+
requestContext.apiKey,
|
|
818
|
+
requestContext.transportSessionId,
|
|
819
|
+
"websocket",
|
|
820
|
+
websocketState,
|
|
821
|
+
);
|
|
822
|
+
const requestBodyForState = structuredCloneJSON(requestContext.transformedBody);
|
|
823
|
+
logCodexDebug("codex websocket request", {
|
|
824
|
+
url: toWebSocketUrl(requestContext.url),
|
|
825
|
+
model: requestContext.transformedBody.model,
|
|
826
|
+
reasoningEffort: requestContext.transformedBody.reasoning?.effort ?? null,
|
|
827
|
+
headers: redactHeaders(websocketHeaders),
|
|
828
|
+
sentTurnStateHeader: websocketHeaders.has(X_CODEX_TURN_STATE_HEADER),
|
|
829
|
+
sentModelsEtagHeader: websocketHeaders.has(X_MODELS_ETAG_HEADER),
|
|
830
|
+
requestType: websocketRequest.type,
|
|
831
|
+
retry,
|
|
832
|
+
retryBudget: getCodexWebSocketRetryBudget(),
|
|
833
|
+
});
|
|
834
|
+
const eventStream = await openCodexWebSocketEventStream(
|
|
835
|
+
toWebSocketUrl(requestContext.url),
|
|
836
|
+
websocketHeaders,
|
|
837
|
+
websocketRequest,
|
|
838
|
+
websocketState,
|
|
839
|
+
{
|
|
840
|
+
idleTimeoutMs: requestSetup.websocketIdleTimeoutMs,
|
|
841
|
+
firstEventTimeoutMs: requestSetup.websocketFirstEventTimeoutMs,
|
|
842
|
+
},
|
|
843
|
+
requestSetup.requestSignal,
|
|
844
|
+
onSseEvent,
|
|
845
|
+
);
|
|
846
|
+
return { eventStream, requestBodyForState, transport: "websocket" };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function openCodexSseTransport(
|
|
850
|
+
model: Model<"openai-codex-responses">,
|
|
851
|
+
requestContext: CodexRequestContext,
|
|
852
|
+
requestSetup: CodexRequestSetup,
|
|
853
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
854
|
+
state: CodexWebSocketSessionState | undefined,
|
|
855
|
+
body = requestContext.transformedBody,
|
|
856
|
+
): Promise<{
|
|
857
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
858
|
+
requestBodyForState: RequestBody;
|
|
859
|
+
transport: CodexTransport;
|
|
860
|
+
}> {
|
|
861
|
+
const eventStream = requestSetup.wrapCodexSseStream(
|
|
862
|
+
await openCodexSseEventStream(
|
|
863
|
+
requestContext.url,
|
|
864
|
+
requestContext.requestHeaders,
|
|
865
|
+
requestContext.accountId,
|
|
866
|
+
requestContext.apiKey,
|
|
867
|
+
requestContext.transportSessionId,
|
|
868
|
+
body,
|
|
869
|
+
state,
|
|
870
|
+
requestSetup.requestSignal,
|
|
871
|
+
event => options?.onSseEvent?.(event, model),
|
|
872
|
+
options?.fetch,
|
|
873
|
+
),
|
|
874
|
+
);
|
|
875
|
+
return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function reopenCodexWebSocketRuntimeStream(
|
|
879
|
+
context: CodexStreamProcessingContext,
|
|
880
|
+
runtime: CodexStreamRuntime,
|
|
881
|
+
state: CodexWebSocketSessionState,
|
|
882
|
+
): Promise<void> {
|
|
883
|
+
try {
|
|
884
|
+
const next = await openCodexWebSocketTransport(
|
|
885
|
+
context.requestContext,
|
|
886
|
+
context.requestSetup,
|
|
887
|
+
state,
|
|
888
|
+
runtime.websocketStreamRetries,
|
|
889
|
+
context.options ? event => context.options?.onSseEvent?.(event, context.model) : undefined,
|
|
890
|
+
);
|
|
891
|
+
runtime.eventStream = next.eventStream;
|
|
892
|
+
runtime.requestBodyForState = next.requestBodyForState;
|
|
893
|
+
runtime.transport = next.transport;
|
|
894
|
+
state.lastTransport = next.transport;
|
|
895
|
+
} catch (error) {
|
|
896
|
+
const wsError = error instanceof Error ? error : new Error(String(error));
|
|
897
|
+
if (!isCodexWebSocketTransportError(wsError)) throw error;
|
|
898
|
+
// Reopen failed at the websocket layer (handshake refused, connect timeout, etc.).
|
|
899
|
+
// Activate fallback so subsequent turns use SSE, and replay this turn over SSE
|
|
900
|
+
// instead of surfacing a raw transport error to the caller.
|
|
901
|
+
recordCodexWebSocketFailure(state, true);
|
|
902
|
+
logCodexDebug("codex websocket reopen failed, falling back to SSE", {
|
|
903
|
+
error: wsError.message,
|
|
904
|
+
retry: runtime.websocketStreamRetries,
|
|
905
|
+
});
|
|
906
|
+
await reopenCodexSseRuntimeStream(context, runtime, state);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function reopenCodexSseRuntimeStream(
|
|
911
|
+
context: CodexStreamProcessingContext,
|
|
912
|
+
runtime: CodexStreamRuntime,
|
|
913
|
+
state: CodexWebSocketSessionState | undefined,
|
|
914
|
+
): Promise<void> {
|
|
915
|
+
const next = await openCodexSseTransport(
|
|
916
|
+
context.model,
|
|
917
|
+
context.requestContext,
|
|
918
|
+
context.requestSetup,
|
|
919
|
+
context.options,
|
|
920
|
+
state,
|
|
921
|
+
);
|
|
922
|
+
runtime.eventStream = next.eventStream;
|
|
923
|
+
runtime.requestBodyForState = next.requestBodyForState;
|
|
924
|
+
runtime.transport = next.transport;
|
|
925
|
+
if (state) {
|
|
926
|
+
state.lastTransport = next.transport;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function createCodexStreamRuntime(initial: {
|
|
931
|
+
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
932
|
+
requestBodyForState: RequestBody;
|
|
933
|
+
transport: CodexTransport;
|
|
934
|
+
websocketState?: CodexWebSocketSessionState;
|
|
935
|
+
}): CodexStreamRuntime {
|
|
936
|
+
return {
|
|
937
|
+
eventStream: initial.eventStream,
|
|
938
|
+
requestBodyForState: initial.requestBodyForState,
|
|
939
|
+
transport: initial.transport,
|
|
940
|
+
websocketState: initial.websocketState,
|
|
941
|
+
currentItem: null,
|
|
942
|
+
currentBlock: null,
|
|
943
|
+
nativeOutputItems: [],
|
|
944
|
+
websocketStreamRetries: 0,
|
|
945
|
+
providerRetryAttempt: 0,
|
|
946
|
+
sawTerminalEvent: false,
|
|
947
|
+
canSafelyReplayWebsocketOverSse: true,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function processCodexResponseStream(
|
|
952
|
+
context: CodexStreamProcessingContext,
|
|
953
|
+
runtime: CodexStreamRuntime,
|
|
954
|
+
): Promise<CodexStreamCompletion> {
|
|
955
|
+
const { output, stream } = context;
|
|
956
|
+
stream.push({ type: "start", partial: output });
|
|
957
|
+
|
|
958
|
+
while (true) {
|
|
959
|
+
try {
|
|
960
|
+
let firstTokenTime = context.firstTokenTime;
|
|
961
|
+
for await (const rawEvent of runtime.eventStream) {
|
|
962
|
+
firstTokenTime = handleCodexStreamEvent({
|
|
963
|
+
...context,
|
|
964
|
+
runtime,
|
|
965
|
+
rawEvent,
|
|
966
|
+
firstTokenTime,
|
|
967
|
+
});
|
|
968
|
+
if (runtime.sawTerminalEvent) break;
|
|
969
|
+
}
|
|
970
|
+
return { firstTokenTime };
|
|
971
|
+
} catch (error) {
|
|
972
|
+
const recovered = await recoverCodexStreamError(context, runtime, error);
|
|
973
|
+
if (!recovered) {
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function handleCodexStreamEvent(args: {
|
|
981
|
+
model: Model<"openai-codex-responses">;
|
|
982
|
+
output: AssistantMessage;
|
|
983
|
+
stream: AssistantMessageEventStream;
|
|
984
|
+
runtime: CodexStreamRuntime;
|
|
985
|
+
rawEvent: Record<string, unknown>;
|
|
986
|
+
firstTokenTime?: number;
|
|
987
|
+
}): number | undefined {
|
|
988
|
+
const { model, output, stream, runtime, rawEvent } = args;
|
|
989
|
+
const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
|
|
990
|
+
if (!eventType) return args.firstTokenTime;
|
|
991
|
+
|
|
992
|
+
const blocks = output.content;
|
|
993
|
+
const blockIndex = () => blocks.length - 1;
|
|
994
|
+
let firstTokenTime = args.firstTokenTime;
|
|
995
|
+
|
|
996
|
+
if (eventType === "response.output_item.added") {
|
|
997
|
+
if (!firstTokenTime) firstTokenTime = Date.now();
|
|
998
|
+
const item = rawEvent.item as CodexEventItem;
|
|
999
|
+
runtime.currentItem = item;
|
|
1000
|
+
runtime.currentBlock = createOutputBlockForItem(item);
|
|
1001
|
+
if (!runtime.currentBlock) return firstTokenTime;
|
|
1002
|
+
output.content.push(runtime.currentBlock);
|
|
1003
|
+
stream.push({
|
|
1004
|
+
type: getOutputBlockStartEventType(runtime.currentBlock),
|
|
1005
|
+
contentIndex: blockIndex(),
|
|
1006
|
+
partial: output,
|
|
1007
|
+
});
|
|
1008
|
+
return firstTokenTime;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (eventType === "response.reasoning_summary_part.added") {
|
|
1012
|
+
handleReasoningSummaryPartAdded(runtime.currentItem, rawEvent);
|
|
1013
|
+
return firstTokenTime;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (eventType === "response.reasoning_summary_text.delta") {
|
|
1017
|
+
handleReasoningSummaryTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
|
|
1018
|
+
return firstTokenTime;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (eventType === "response.reasoning_summary_part.done") {
|
|
1022
|
+
handleReasoningSummaryPartDone(runtime.currentItem, runtime.currentBlock, stream, output, blockIndex);
|
|
1023
|
+
return firstTokenTime;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (eventType === "response.content_part.added") {
|
|
1027
|
+
handleContentPartAdded(runtime.currentItem, rawEvent);
|
|
1028
|
+
return firstTokenTime;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (eventType === "response.output_text.delta") {
|
|
1032
|
+
handleMessageTextDelta(
|
|
1033
|
+
runtime.currentItem,
|
|
1034
|
+
runtime.currentBlock,
|
|
1035
|
+
rawEvent,
|
|
1036
|
+
stream,
|
|
1037
|
+
output,
|
|
1038
|
+
blockIndex,
|
|
1039
|
+
"output_text",
|
|
1040
|
+
);
|
|
1041
|
+
return firstTokenTime;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (eventType === "response.refusal.delta") {
|
|
1045
|
+
handleMessageTextDelta(
|
|
1046
|
+
runtime.currentItem,
|
|
1047
|
+
runtime.currentBlock,
|
|
1048
|
+
rawEvent,
|
|
1049
|
+
stream,
|
|
1050
|
+
output,
|
|
1051
|
+
blockIndex,
|
|
1052
|
+
"refusal",
|
|
1053
|
+
);
|
|
1054
|
+
return firstTokenTime;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (eventType === "response.function_call_arguments.delta") {
|
|
1058
|
+
handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
|
|
1059
|
+
return firstTokenTime;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (eventType === "response.function_call_arguments.done") {
|
|
1063
|
+
handleToolCallArgumentsDone(runtime.currentItem, runtime.currentBlock, rawEvent);
|
|
1064
|
+
return firstTokenTime;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (eventType === "response.custom_tool_call_input.delta") {
|
|
1068
|
+
handleCustomToolCallInputDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
|
|
1069
|
+
return firstTokenTime;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (eventType === "response.custom_tool_call_input.done") {
|
|
1073
|
+
handleCustomToolCallInputDone(runtime.currentItem, runtime.currentBlock, rawEvent);
|
|
1074
|
+
return firstTokenTime;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (eventType === "response.output_item.done") {
|
|
1078
|
+
handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex);
|
|
1079
|
+
return firstTokenTime;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (eventType === "response.created") {
|
|
1083
|
+
return handleResponseCreated(runtime, rawEvent);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
|
|
1087
|
+
handleResponseCompleted(model, output, runtime, rawEvent);
|
|
1088
|
+
return firstTokenTime;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (eventType === "error" || eventType === "response.failed") {
|
|
1092
|
+
throw createCodexProviderStreamError(rawEvent);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return firstTokenTime;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null {
|
|
1099
|
+
if (item.type === "reasoning") {
|
|
1100
|
+
return { type: "thinking", thinking: "" };
|
|
1101
|
+
}
|
|
1102
|
+
if (item.type === "message") {
|
|
1103
|
+
return { type: "text", text: "" };
|
|
1104
|
+
}
|
|
1105
|
+
if (item.type === "function_call") {
|
|
1106
|
+
return {
|
|
1107
|
+
type: "toolCall",
|
|
1108
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1109
|
+
name: item.name,
|
|
1110
|
+
arguments: {},
|
|
1111
|
+
partialJson: item.arguments || "",
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
if (item.type === "custom_tool_call") {
|
|
1115
|
+
// Wire name flows through unchanged; the agent-loop dispatcher also
|
|
1116
|
+
// matches `Tool.customWireName`. Reuse `partialJson` as the
|
|
1117
|
+
// accumulation buffer for the raw input string.
|
|
1118
|
+
return {
|
|
1119
|
+
type: "toolCall",
|
|
1120
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1121
|
+
name: item.name,
|
|
1122
|
+
arguments: { input: item.input ?? "" },
|
|
1123
|
+
customWireName: item.name,
|
|
1124
|
+
partialJson: item.input ?? "",
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start" | "text_start" | "toolcall_start" {
|
|
1131
|
+
if (block.type === "thinking") return "thinking_start";
|
|
1132
|
+
if (block.type === "text") return "text_start";
|
|
1133
|
+
return "toolcall_start";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function handleReasoningSummaryPartAdded(currentItem: CodexEventItem | null, rawEvent: Record<string, unknown>): void {
|
|
1137
|
+
if (currentItem?.type !== "reasoning") return;
|
|
1138
|
+
currentItem.summary = currentItem.summary || [];
|
|
1139
|
+
currentItem.summary.push((rawEvent as { part: ResponseReasoningItem["summary"][number] }).part);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function handleReasoningSummaryTextDelta(
|
|
1143
|
+
currentItem: CodexEventItem | null,
|
|
1144
|
+
currentBlock: CodexOutputBlock | null,
|
|
1145
|
+
rawEvent: Record<string, unknown>,
|
|
1146
|
+
stream: AssistantMessageEventStream,
|
|
1147
|
+
output: AssistantMessage,
|
|
1148
|
+
blockIndex: () => number,
|
|
1149
|
+
): void {
|
|
1150
|
+
if (currentItem?.type !== "reasoning" || currentBlock?.type !== "thinking") return;
|
|
1151
|
+
currentItem.summary = currentItem.summary || [];
|
|
1152
|
+
const lastPart = currentItem.summary[currentItem.summary.length - 1];
|
|
1153
|
+
if (!lastPart) return;
|
|
1154
|
+
const delta = (rawEvent as { delta?: string }).delta || "";
|
|
1155
|
+
currentBlock.thinking += delta;
|
|
1156
|
+
lastPart.text += delta;
|
|
1157
|
+
stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta, partial: output });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function handleReasoningSummaryPartDone(
|
|
1161
|
+
currentItem: CodexEventItem | null,
|
|
1162
|
+
currentBlock: CodexOutputBlock | null,
|
|
1163
|
+
stream: AssistantMessageEventStream,
|
|
1164
|
+
output: AssistantMessage,
|
|
1165
|
+
blockIndex: () => number,
|
|
1166
|
+
): void {
|
|
1167
|
+
if (currentItem?.type !== "reasoning" || currentBlock?.type !== "thinking") return;
|
|
1168
|
+
currentItem.summary = currentItem.summary || [];
|
|
1169
|
+
const lastPart = currentItem.summary[currentItem.summary.length - 1];
|
|
1170
|
+
if (!lastPart) return;
|
|
1171
|
+
currentBlock.thinking += "\n\n";
|
|
1172
|
+
lastPart.text += "\n\n";
|
|
1173
|
+
stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta: "\n\n", partial: output });
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function handleContentPartAdded(currentItem: CodexEventItem | null, rawEvent: Record<string, unknown>): void {
|
|
1177
|
+
if (currentItem?.type !== "message") return;
|
|
1178
|
+
currentItem.content = currentItem.content || [];
|
|
1179
|
+
const part = (rawEvent as { part?: ResponseOutputMessage["content"][number] }).part;
|
|
1180
|
+
if (part && (part.type === "output_text" || part.type === "refusal")) {
|
|
1181
|
+
currentItem.content.push(part);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function handleMessageTextDelta(
|
|
1186
|
+
currentItem: CodexEventItem | null,
|
|
1187
|
+
currentBlock: CodexOutputBlock | null,
|
|
1188
|
+
rawEvent: Record<string, unknown>,
|
|
1189
|
+
stream: AssistantMessageEventStream,
|
|
1190
|
+
output: AssistantMessage,
|
|
1191
|
+
blockIndex: () => number,
|
|
1192
|
+
partType: "output_text" | "refusal",
|
|
1193
|
+
): void {
|
|
1194
|
+
if (currentItem?.type !== "message" || currentBlock?.type !== "text") return;
|
|
1195
|
+
if (!currentItem.content || currentItem.content.length === 0) return;
|
|
1196
|
+
const lastPart = currentItem.content[currentItem.content.length - 1];
|
|
1197
|
+
if (!lastPart || lastPart.type !== partType) return;
|
|
1198
|
+
const delta = (rawEvent as { delta?: string }).delta || "";
|
|
1199
|
+
currentBlock.text += delta;
|
|
1200
|
+
if (lastPart.type === "output_text") {
|
|
1201
|
+
lastPart.text += delta;
|
|
1202
|
+
} else {
|
|
1203
|
+
lastPart.refusal += delta;
|
|
1204
|
+
}
|
|
1205
|
+
stream.push({ type: "text_delta", contentIndex: blockIndex(), delta, partial: output });
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function handleToolCallArgumentsDelta(
|
|
1209
|
+
currentItem: CodexEventItem | null,
|
|
1210
|
+
currentBlock: CodexOutputBlock | null,
|
|
1211
|
+
rawEvent: Record<string, unknown>,
|
|
1212
|
+
stream: AssistantMessageEventStream,
|
|
1213
|
+
output: AssistantMessage,
|
|
1214
|
+
blockIndex: () => number,
|
|
1215
|
+
): void {
|
|
1216
|
+
if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return;
|
|
1217
|
+
const delta = (rawEvent as { delta?: string }).delta || "";
|
|
1218
|
+
currentBlock.partialJson += delta;
|
|
1219
|
+
const throttled = parseStreamingJsonThrottled(currentBlock.partialJson, currentBlock.lastParseLen ?? 0);
|
|
1220
|
+
if (throttled) {
|
|
1221
|
+
currentBlock.arguments = throttled.value;
|
|
1222
|
+
currentBlock.lastParseLen = throttled.parsedLen;
|
|
1223
|
+
}
|
|
1224
|
+
stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function handleToolCallArgumentsDone(
|
|
1228
|
+
currentItem: CodexEventItem | null,
|
|
1229
|
+
currentBlock: CodexOutputBlock | null,
|
|
1230
|
+
rawEvent: Record<string, unknown>,
|
|
1231
|
+
): void {
|
|
1232
|
+
if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return;
|
|
1233
|
+
const args = (rawEvent as { arguments?: string }).arguments;
|
|
1234
|
+
if (typeof args === "string") {
|
|
1235
|
+
currentBlock.partialJson = args;
|
|
1236
|
+
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
|
|
1237
|
+
delete (currentBlock as { partialJson?: string }).partialJson;
|
|
1238
|
+
delete (currentBlock as { lastParseLen?: number }).lastParseLen;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function handleCustomToolCallInputDelta(
|
|
1243
|
+
currentItem: CodexEventItem | null,
|
|
1244
|
+
currentBlock: CodexOutputBlock | null,
|
|
1245
|
+
rawEvent: Record<string, unknown>,
|
|
1246
|
+
stream: AssistantMessageEventStream,
|
|
1247
|
+
output: AssistantMessage,
|
|
1248
|
+
blockIndex: () => number,
|
|
1249
|
+
): void {
|
|
1250
|
+
if (currentItem?.type !== "custom_tool_call" || currentBlock?.type !== "toolCall") return;
|
|
1251
|
+
const delta = (rawEvent as { delta?: string }).delta || "";
|
|
1252
|
+
currentBlock.partialJson += delta;
|
|
1253
|
+
currentBlock.arguments = { input: currentBlock.partialJson };
|
|
1254
|
+
stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function handleCustomToolCallInputDone(
|
|
1258
|
+
currentItem: CodexEventItem | null,
|
|
1259
|
+
currentBlock: CodexOutputBlock | null,
|
|
1260
|
+
rawEvent: Record<string, unknown>,
|
|
1261
|
+
): void {
|
|
1262
|
+
if (currentItem?.type !== "custom_tool_call" || currentBlock?.type !== "toolCall") return;
|
|
1263
|
+
const input = (rawEvent as { input?: string }).input;
|
|
1264
|
+
if (typeof input === "string") {
|
|
1265
|
+
currentBlock.partialJson = input;
|
|
1266
|
+
currentBlock.arguments = { input };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function handleOutputItemDone(
|
|
1271
|
+
model: Model<"openai-codex-responses">,
|
|
1272
|
+
output: AssistantMessage,
|
|
1273
|
+
stream: AssistantMessageEventStream,
|
|
1274
|
+
runtime: CodexStreamRuntime,
|
|
1275
|
+
rawEvent: Record<string, unknown>,
|
|
1276
|
+
blockIndex: () => number,
|
|
1277
|
+
): void {
|
|
1278
|
+
const item = structuredCloneJSON(rawEvent.item) as CodexEventItem;
|
|
1279
|
+
runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
|
|
1280
|
+
|
|
1281
|
+
if (item.type === "reasoning" && runtime.currentBlock?.type === "thinking") {
|
|
1282
|
+
runtime.currentBlock.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
|
|
1283
|
+
runtime.currentBlock.thinkingSignature = JSON.stringify(item);
|
|
1284
|
+
stream.push({
|
|
1285
|
+
type: "thinking_end",
|
|
1286
|
+
contentIndex: blockIndex(),
|
|
1287
|
+
content: runtime.currentBlock.thinking,
|
|
1288
|
+
partial: output,
|
|
1289
|
+
});
|
|
1290
|
+
runtime.currentBlock = null;
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (item.type === "message" && runtime.currentBlock?.type === "text") {
|
|
1295
|
+
runtime.currentBlock.text = item.content
|
|
1296
|
+
.map(content => (content.type === "output_text" ? content.text : content.refusal))
|
|
1297
|
+
.join("");
|
|
1298
|
+
const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
|
|
1299
|
+
runtime.currentBlock.textSignature = encodeTextSignatureV1(item.id, phase);
|
|
1300
|
+
stream.push({
|
|
1301
|
+
type: "text_end",
|
|
1302
|
+
contentIndex: blockIndex(),
|
|
1303
|
+
content: runtime.currentBlock.text,
|
|
1304
|
+
partial: output,
|
|
1305
|
+
});
|
|
1306
|
+
runtime.currentBlock = null;
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (item.type === "function_call") {
|
|
1311
|
+
const toolCall: ToolCall = {
|
|
1312
|
+
type: "toolCall",
|
|
1313
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1314
|
+
name: item.name,
|
|
1315
|
+
arguments: parseStreamingJson(item.arguments || "{}"),
|
|
1316
|
+
};
|
|
1317
|
+
if (runtime.currentBlock?.type === "toolCall") {
|
|
1318
|
+
// Persist the authoritative final args on the stored block; the throttled
|
|
1319
|
+
// delta parser may have left currentBlock.arguments stale (often `{}`).
|
|
1320
|
+
runtime.currentBlock.arguments = toolCall.arguments;
|
|
1321
|
+
delete (runtime.currentBlock as { partialJson?: string }).partialJson;
|
|
1322
|
+
delete (runtime.currentBlock as { lastParseLen?: number }).lastParseLen;
|
|
1323
|
+
}
|
|
1324
|
+
runtime.canSafelyReplayWebsocketOverSse = false;
|
|
1325
|
+
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (item.type === "custom_tool_call") {
|
|
1330
|
+
const rawInput =
|
|
1331
|
+
runtime.currentBlock?.type === "toolCall" && runtime.currentBlock.partialJson
|
|
1332
|
+
? runtime.currentBlock.partialJson
|
|
1333
|
+
: (item.input ?? "");
|
|
1334
|
+
const toolCall: ToolCall = {
|
|
1335
|
+
type: "toolCall",
|
|
1336
|
+
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1337
|
+
name: item.name,
|
|
1338
|
+
arguments: { input: rawInput },
|
|
1339
|
+
customWireName: item.name,
|
|
1340
|
+
};
|
|
1341
|
+
runtime.canSafelyReplayWebsocketOverSse = false;
|
|
1342
|
+
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
void model;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function handleResponseCreated(runtime: CodexStreamRuntime, rawEvent: Record<string, unknown>): number | undefined {
|
|
1350
|
+
const response = (rawEvent as { response?: { id?: string } }).response;
|
|
1351
|
+
const state = runtime.websocketState;
|
|
1352
|
+
if (runtime.transport === "websocket" && state && typeof response?.id === "string" && response.id.length > 0) {
|
|
1353
|
+
state.lastResponseId = response.id;
|
|
1354
|
+
}
|
|
1355
|
+
return undefined;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function handleResponseCompleted(
|
|
1359
|
+
model: Model<"openai-codex-responses">,
|
|
1360
|
+
output: AssistantMessage,
|
|
1361
|
+
runtime: CodexStreamRuntime,
|
|
1362
|
+
rawEvent: Record<string, unknown>,
|
|
1363
|
+
): void {
|
|
1364
|
+
runtime.sawTerminalEvent = true;
|
|
1365
|
+
const response = (
|
|
1366
|
+
rawEvent as {
|
|
1367
|
+
response?: {
|
|
1368
|
+
id?: string;
|
|
1369
|
+
usage?: {
|
|
1370
|
+
input_tokens?: number;
|
|
1371
|
+
output_tokens?: number;
|
|
1372
|
+
total_tokens?: number;
|
|
1373
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
1374
|
+
output_tokens_details?: { reasoning_tokens?: number };
|
|
1375
|
+
};
|
|
1376
|
+
status?: string;
|
|
1377
|
+
service_tier?: ServiceTier | "default";
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
).response;
|
|
1381
|
+
|
|
1382
|
+
populateResponsesUsageFromResponse(output, response?.usage);
|
|
1383
|
+
if (typeof response?.id === "string" && response.id.length > 0) {
|
|
1384
|
+
output.responseId = response.id;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const state = runtime.websocketState;
|
|
1388
|
+
if (runtime.transport === "websocket" && state) {
|
|
1389
|
+
state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
|
|
1390
|
+
if (typeof response?.id === "string" && response.id.length > 0) {
|
|
1391
|
+
state.lastResponseId = response.id;
|
|
1392
|
+
state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
|
|
1393
|
+
}
|
|
1394
|
+
state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
calculateCost(model, output.usage);
|
|
1398
|
+
applyCodexServiceTierPricing(model, output.usage, response?.service_tier, runtime.requestBodyForState.service_tier);
|
|
1399
|
+
output.stopReason = mapOpenAIResponsesStopReason(response?.status as OpenAI.Responses.ResponseStatus | undefined);
|
|
1400
|
+
if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
|
|
1401
|
+
output.stopReason = "toolUse";
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async function recoverCodexStreamError(
|
|
1406
|
+
context: CodexStreamProcessingContext,
|
|
1407
|
+
runtime: CodexStreamRuntime,
|
|
1408
|
+
error: unknown,
|
|
1409
|
+
): Promise<boolean> {
|
|
1410
|
+
if (await tryReconnectCodexWebSocketOnConnectionLimit(context, runtime, error)) {
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
if (await tryRecoverCodexPreviousResponseNotFound(context, runtime, error)) {
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
if (await tryReplayWebsocketFailureOverSse(context, runtime, error)) {
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
if (await tryRetryCodexProviderError(context, runtime, error)) {
|
|
1420
|
+
return true;
|
|
1421
|
+
}
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Handles `websocket_connection_limit_reached` errors by closing the stale connection
|
|
1427
|
+
* and opening a fresh websocket. If content has already been emitted to the caller,
|
|
1428
|
+
* falls back to SSE replay (same as other WS failures) since we cannot safely
|
|
1429
|
+
* continue a partial response on a new connection.
|
|
1430
|
+
*/
|
|
1431
|
+
async function tryReconnectCodexWebSocketOnConnectionLimit(
|
|
1432
|
+
context: CodexStreamProcessingContext,
|
|
1433
|
+
runtime: CodexStreamRuntime,
|
|
1434
|
+
error: unknown,
|
|
1435
|
+
): Promise<boolean> {
|
|
1436
|
+
if (!(error instanceof CodexProviderStreamError) || error.code !== "websocket_connection_limit_reached") {
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
const websocketState = context.requestContext.websocketState;
|
|
1440
|
+
if (!websocketState || runtime.transport !== "websocket" || context.options?.signal?.aborted) {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Close the stale connection so getOrCreateCodexWebSocketConnection creates a fresh one.
|
|
1445
|
+
websocketState.connection?.close("connection_limit");
|
|
1446
|
+
websocketState.connection = undefined;
|
|
1447
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1448
|
+
|
|
1449
|
+
logCodexDebug("codex websocket connection limit reached, reconnecting", {
|
|
1450
|
+
hadContent: context.output.content.length > 0,
|
|
1451
|
+
retry: runtime.websocketStreamRetries,
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
if (context.output.content.length > 0) {
|
|
1455
|
+
// Content already emitted to the caller — cannot safely continue on a new WS.
|
|
1456
|
+
// Reset and replay the full request over SSE.
|
|
1457
|
+
runtime.canSafelyReplayWebsocketOverSse = true;
|
|
1458
|
+
runtime.currentItem = null;
|
|
1459
|
+
runtime.currentBlock = null;
|
|
1460
|
+
runtime.nativeOutputItems.length = 0;
|
|
1461
|
+
resetOutputState(context.output);
|
|
1462
|
+
context.firstTokenTime = undefined;
|
|
1463
|
+
recordCodexWebSocketFailure(websocketState, true);
|
|
1464
|
+
await reopenCodexSseRuntimeStream(context, runtime, websocketState);
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// No content emitted yet — reconnect over websocket.
|
|
1469
|
+
runtime.websocketStreamRetries += 1;
|
|
1470
|
+
await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function isCodexPreviousResponseNotFound(error: unknown): boolean {
|
|
1475
|
+
return error instanceof CodexProviderStreamError && error.code === "previous_response_not_found";
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async function tryRecoverCodexPreviousResponseNotFound(
|
|
1479
|
+
context: CodexStreamProcessingContext,
|
|
1480
|
+
runtime: CodexStreamRuntime,
|
|
1481
|
+
error: unknown,
|
|
1482
|
+
): Promise<boolean> {
|
|
1483
|
+
const websocketState = context.requestContext.websocketState;
|
|
1484
|
+
if (
|
|
1485
|
+
!isCodexPreviousResponseNotFound(error) ||
|
|
1486
|
+
!websocketState ||
|
|
1487
|
+
runtime.transport !== "websocket" ||
|
|
1488
|
+
context.output.content.length > 0 ||
|
|
1489
|
+
context.options?.signal?.aborted ||
|
|
1490
|
+
runtime.providerRetryAttempt >= CODEX_MAX_RETRIES
|
|
1491
|
+
) {
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
runtime.providerRetryAttempt += 1;
|
|
1496
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1497
|
+
resetCodexSessionMetadata(websocketState);
|
|
1498
|
+
runtime.currentItem = null;
|
|
1499
|
+
runtime.currentBlock = null;
|
|
1500
|
+
runtime.sawTerminalEvent = false;
|
|
1501
|
+
runtime.nativeOutputItems.length = 0;
|
|
1502
|
+
resetOutputState(context.output);
|
|
1503
|
+
context.firstTokenTime = undefined;
|
|
1504
|
+
|
|
1505
|
+
logCodexDebug("codex previous_response_id expired; retrying with full context", {
|
|
1506
|
+
retry: runtime.providerRetryAttempt,
|
|
1507
|
+
});
|
|
1508
|
+
await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async function tryReplayWebsocketFailureOverSse(
|
|
1513
|
+
context: CodexStreamProcessingContext,
|
|
1514
|
+
runtime: CodexStreamRuntime,
|
|
1515
|
+
error: unknown,
|
|
1516
|
+
): Promise<boolean> {
|
|
1517
|
+
const websocketState = context.requestContext.websocketState;
|
|
1518
|
+
const canReplay =
|
|
1519
|
+
runtime.transport === "websocket" &&
|
|
1520
|
+
websocketState &&
|
|
1521
|
+
isCodexWebSocketRetryableStreamError(error) &&
|
|
1522
|
+
runtime.canSafelyReplayWebsocketOverSse &&
|
|
1523
|
+
!runtime.sawTerminalEvent &&
|
|
1524
|
+
!context.options?.signal?.aborted;
|
|
1525
|
+
if (!canReplay) return false;
|
|
1526
|
+
|
|
1527
|
+
const state = websocketState;
|
|
1528
|
+
const streamError = error instanceof Error ? error : new Error(String(error));
|
|
1529
|
+
const replayingBufferedOutputOverSse = context.output.content.length > 0;
|
|
1530
|
+
const isFatal = isCodexWebSocketFatalError(streamError);
|
|
1531
|
+
const activateFallback =
|
|
1532
|
+
replayingBufferedOutputOverSse || isFatal || runtime.websocketStreamRetries >= getCodexWebSocketRetryBudget();
|
|
1533
|
+
recordCodexWebSocketFailure(state, activateFallback);
|
|
1534
|
+
logCodexDebug("codex websocket stream fallback", {
|
|
1535
|
+
error: streamError.message,
|
|
1536
|
+
retry: runtime.websocketStreamRetries,
|
|
1537
|
+
retryBudget: getCodexWebSocketRetryBudget(),
|
|
1538
|
+
activated: activateFallback,
|
|
1539
|
+
fatal: isFatal,
|
|
1540
|
+
replayedBufferedOutput: replayingBufferedOutputOverSse,
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
if (!activateFallback) {
|
|
1544
|
+
runtime.websocketStreamRetries += 1;
|
|
1545
|
+
await scheduler.wait(getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), {
|
|
1546
|
+
signal: context.requestSetup.requestSignal,
|
|
1547
|
+
});
|
|
1548
|
+
await reopenCodexWebSocketRuntimeStream(context, runtime, state);
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (replayingBufferedOutputOverSse) {
|
|
1553
|
+
runtime.canSafelyReplayWebsocketOverSse = true;
|
|
1554
|
+
runtime.currentItem = null;
|
|
1555
|
+
runtime.currentBlock = null;
|
|
1556
|
+
runtime.nativeOutputItems.length = 0;
|
|
1557
|
+
resetOutputState(context.output);
|
|
1558
|
+
context.firstTokenTime = undefined;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
await reopenCodexSseRuntimeStream(context, runtime, state);
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
async function tryRetryCodexProviderError(
|
|
1566
|
+
context: CodexStreamProcessingContext,
|
|
1567
|
+
runtime: CodexStreamRuntime,
|
|
1568
|
+
error: unknown,
|
|
1569
|
+
): Promise<boolean> {
|
|
1570
|
+
if (
|
|
1571
|
+
!isRetryableCodexProviderError(error) ||
|
|
1572
|
+
context.output.content.length > 0 ||
|
|
1573
|
+
runtime.providerRetryAttempt >= CODEX_MAX_RETRIES ||
|
|
1574
|
+
context.options?.signal?.aborted
|
|
1575
|
+
) {
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
runtime.providerRetryAttempt += 1;
|
|
1580
|
+
const websocketState = context.requestContext.websocketState;
|
|
1581
|
+
if (runtime.transport === "websocket" && websocketState) {
|
|
1582
|
+
resetCodexWebSocketAppendState(websocketState);
|
|
1583
|
+
resetCodexSessionMetadata(websocketState);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
logCodexDebug("retrying codex provider stream error", {
|
|
1587
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1588
|
+
retry: runtime.providerRetryAttempt,
|
|
1589
|
+
retryBudget: CODEX_MAX_RETRIES,
|
|
1590
|
+
transport: runtime.transport,
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
runtime.currentItem = null;
|
|
1594
|
+
runtime.currentBlock = null;
|
|
1595
|
+
runtime.sawTerminalEvent = false;
|
|
1596
|
+
resetOutputState(context.output);
|
|
1597
|
+
context.firstTokenTime = undefined;
|
|
1598
|
+
await scheduler.wait(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, {
|
|
1599
|
+
signal: context.requestSetup.requestSignal,
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
if (runtime.transport === "websocket" && websocketState) {
|
|
1603
|
+
await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
|
|
1604
|
+
return true;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
await reopenCodexSseRuntimeStream(context, runtime, websocketState);
|
|
1608
|
+
return true;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function finalizeCodexResponse(
|
|
1612
|
+
context: CodexStreamProcessingContext,
|
|
1613
|
+
runtime: CodexStreamRuntime,
|
|
1614
|
+
completion: CodexStreamCompletion,
|
|
1615
|
+
): AssistantMessage {
|
|
1616
|
+
const { output } = context;
|
|
1617
|
+
if (context.options?.signal?.aborted) {
|
|
1618
|
+
throw new Error("Request was aborted");
|
|
1619
|
+
}
|
|
1620
|
+
if (!runtime.sawTerminalEvent) {
|
|
1621
|
+
if (runtime.transport === "websocket" && context.requestContext.websocketState) {
|
|
1622
|
+
resetCodexWebSocketAppendState(context.requestContext.websocketState);
|
|
1623
|
+
resetCodexSessionMetadata(context.requestContext.websocketState);
|
|
1624
|
+
}
|
|
1625
|
+
logCodexDebug("codex stream ended unexpectedly", {
|
|
1626
|
+
transport: runtime.transport,
|
|
1627
|
+
terminalEventSeen: runtime.sawTerminalEvent,
|
|
1628
|
+
unexpectedStreamEnd: true,
|
|
1629
|
+
sentTurnStateHeader: Boolean(context.requestContext.websocketState?.turnState),
|
|
1630
|
+
sentModelsEtagHeader: Boolean(context.requestContext.websocketState?.modelsEtag),
|
|
1631
|
+
});
|
|
1632
|
+
throw new Error("Codex stream ended before terminal completion event");
|
|
1633
|
+
}
|
|
1634
|
+
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
|
1635
|
+
throw new Error("Codex response failed");
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
output.providerPayload = createOpenAIResponsesHistoryPayload(context.model.provider, runtime.nativeOutputItems);
|
|
1639
|
+
output.duration = Date.now() - context.startTime;
|
|
1640
|
+
if (completion.firstTokenTime) {
|
|
1641
|
+
output.ttft = completion.firstTokenTime - context.startTime;
|
|
1642
|
+
}
|
|
1643
|
+
return output;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function handleCodexStreamFailure(
|
|
1647
|
+
context: CodexStreamProcessingContext,
|
|
1648
|
+
error: unknown,
|
|
1649
|
+
): Promise<AssistantMessage> {
|
|
1650
|
+
const { output } = context;
|
|
1651
|
+
removeTransientBlockIndices(output);
|
|
1652
|
+
if (context.requestContext.websocketState) {
|
|
1653
|
+
resetCodexWebSocketAppendState(context.requestContext.websocketState);
|
|
1654
|
+
resetCodexSessionMetadata(context.requestContext.websocketState);
|
|
1655
|
+
}
|
|
1656
|
+
output.stopReason = context.options?.signal?.aborted ? "aborted" : "error";
|
|
1657
|
+
output.errorStatus = extractHttpStatusFromError(error);
|
|
1658
|
+
output.errorMessage = await finalizeErrorMessage(error, context.requestContext.rawRequestDump);
|
|
1659
|
+
output.duration = Date.now() - context.startTime;
|
|
1660
|
+
if (context.firstTokenTime) {
|
|
1661
|
+
output.ttft = context.firstTokenTime - context.startTime;
|
|
1662
|
+
}
|
|
1663
|
+
return output;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = (
|
|
1667
|
+
model: Model<"openai-codex-responses">,
|
|
1668
|
+
context: Context,
|
|
1669
|
+
options?: OpenAICodexResponsesOptions,
|
|
1670
|
+
): AssistantMessageEventStream => {
|
|
1671
|
+
const stream = new AssistantMessageEventStream();
|
|
1672
|
+
|
|
1673
|
+
(async () => {
|
|
1674
|
+
const startTime = Date.now();
|
|
1675
|
+
const output = createAssistantOutput(model);
|
|
1676
|
+
const requestSetup = createRequestSetup(options);
|
|
1677
|
+
let processingContext: CodexStreamProcessingContext | undefined;
|
|
1678
|
+
|
|
1679
|
+
try {
|
|
1680
|
+
const requestContext = await buildCodexRequestContext(model, context, options, output);
|
|
1681
|
+
const initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
|
|
1682
|
+
const runtime = createCodexStreamRuntime({
|
|
1683
|
+
...initialTransport,
|
|
1684
|
+
websocketState: requestContext.websocketState,
|
|
1685
|
+
});
|
|
1686
|
+
if (requestContext.websocketState) {
|
|
1687
|
+
requestContext.websocketState.lastTransport = initialTransport.transport;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
processingContext = {
|
|
1691
|
+
model,
|
|
1692
|
+
output,
|
|
1693
|
+
stream,
|
|
1694
|
+
options,
|
|
1695
|
+
requestSetup,
|
|
1696
|
+
requestContext,
|
|
1697
|
+
startTime,
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
const completion = await processCodexResponseStream(processingContext, runtime);
|
|
1701
|
+
processingContext.firstTokenTime = completion.firstTokenTime;
|
|
1702
|
+
const message = finalizeCodexResponse(processingContext, runtime, completion);
|
|
1703
|
+
stream.push({ type: "done", reason: message.stopReason as "stop" | "length" | "toolUse", message });
|
|
1704
|
+
stream.end();
|
|
1705
|
+
} catch (error) {
|
|
1706
|
+
const failureContext =
|
|
1707
|
+
processingContext ??
|
|
1708
|
+
({
|
|
1709
|
+
model,
|
|
1710
|
+
output,
|
|
1711
|
+
stream,
|
|
1712
|
+
options,
|
|
1713
|
+
requestSetup,
|
|
1714
|
+
requestContext: {
|
|
1715
|
+
apiKey: "",
|
|
1716
|
+
accountId: "",
|
|
1717
|
+
baseUrl: model.baseUrl || CODEX_BASE_URL,
|
|
1718
|
+
url: "",
|
|
1719
|
+
requestHeaders: {},
|
|
1720
|
+
transformedBody: { model: model.id },
|
|
1721
|
+
rawRequestDump: {
|
|
1722
|
+
provider: model.provider,
|
|
1723
|
+
api: output.api,
|
|
1724
|
+
model: model.id,
|
|
1725
|
+
method: "POST",
|
|
1726
|
+
url: "",
|
|
1727
|
+
body: { model: model.id },
|
|
1728
|
+
},
|
|
1729
|
+
},
|
|
1730
|
+
startTime,
|
|
1731
|
+
} satisfies CodexStreamProcessingContext);
|
|
1732
|
+
const failure = await handleCodexStreamFailure(failureContext, error);
|
|
1733
|
+
stream.push({ type: "error", reason: failure.stopReason as "error" | "aborted", error: failure });
|
|
1734
|
+
stream.end();
|
|
1735
|
+
}
|
|
1736
|
+
})();
|
|
1737
|
+
|
|
1738
|
+
return stream;
|
|
1739
|
+
};
|
|
1740
|
+
|
|
1741
|
+
export async function prewarmOpenAICodexResponses(
|
|
1742
|
+
model: Model<"openai-codex-responses">,
|
|
1743
|
+
options?: Pick<
|
|
1744
|
+
OpenAICodexResponsesOptions,
|
|
1745
|
+
"apiKey" | "headers" | "sessionId" | "signal" | "preferWebsockets" | "providerSessionState"
|
|
1746
|
+
>,
|
|
1747
|
+
): Promise<void> {
|
|
1748
|
+
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
|
1749
|
+
if (!apiKey) return;
|
|
1750
|
+
const accountId = getAccountId(apiKey);
|
|
1751
|
+
const baseUrl = model.baseUrl || CODEX_BASE_URL;
|
|
1752
|
+
const url = resolveCodexResponsesUrl(baseUrl);
|
|
1753
|
+
const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
|
1754
|
+
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
1755
|
+
const sessionKey = getCodexWebSocketSessionKey(promptCacheKey, model, accountId, baseUrl);
|
|
1756
|
+
const publicSessionKey = getCodexPublicSessionKey(promptCacheKey, model, baseUrl);
|
|
1757
|
+
if (publicSessionKey && sessionKey) {
|
|
1758
|
+
providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
|
|
1759
|
+
}
|
|
1760
|
+
if (!sessionKey || !providerSessionState) return;
|
|
1761
|
+
const state = getCodexWebSocketSessionState(sessionKey, providerSessionState);
|
|
1762
|
+
if (!shouldUseCodexWebSocket(model, state, options?.preferWebsockets)) return;
|
|
1763
|
+
const headers = logger.time(
|
|
1764
|
+
"prewarmCodex:createHeaders",
|
|
1765
|
+
createCodexHeaders,
|
|
1766
|
+
{ ...(model.headers ?? {}), ...(options?.headers ?? {}) },
|
|
1767
|
+
accountId,
|
|
1768
|
+
apiKey,
|
|
1769
|
+
promptCacheKey,
|
|
1770
|
+
"websocket",
|
|
1771
|
+
state,
|
|
1772
|
+
);
|
|
1773
|
+
await logger.time(
|
|
1774
|
+
"prewarmCodex:establishWs",
|
|
1775
|
+
getOrCreateCodexWebSocketConnection,
|
|
1776
|
+
state,
|
|
1777
|
+
toWebSocketUrl(url),
|
|
1778
|
+
headers,
|
|
1779
|
+
options?.signal,
|
|
1780
|
+
);
|
|
1781
|
+
state.prewarmed = true;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function resolveCodexPromptCacheKey(
|
|
1785
|
+
options: Pick<OpenAICodexResponsesOptions, "promptCacheKey" | "sessionId"> | undefined,
|
|
1786
|
+
): string | undefined {
|
|
1787
|
+
return normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function resolveCodexTransportSessionId(
|
|
1791
|
+
options: Pick<OpenAICodexResponsesOptions, "sessionId"> | undefined,
|
|
1792
|
+
): string | undefined {
|
|
1793
|
+
return normalizeOpenAIResponsesPromptCacheKey(options?.sessionId);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function getCodexWebSocketSessionKey(
|
|
1797
|
+
sessionId: string | undefined,
|
|
1798
|
+
model: Model<"openai-codex-responses">,
|
|
1799
|
+
accountId: string,
|
|
1800
|
+
baseUrl: string,
|
|
1801
|
+
): string | undefined {
|
|
1802
|
+
const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(sessionId);
|
|
1803
|
+
if (!promptCacheKey) return undefined;
|
|
1804
|
+
return `${accountId}:${baseUrl}:${model.id}:${promptCacheKey}`;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function getCodexPublicSessionKey(
|
|
1808
|
+
sessionId: string | undefined,
|
|
1809
|
+
model: Model<"openai-codex-responses">,
|
|
1810
|
+
baseUrl: string,
|
|
1811
|
+
): string | undefined {
|
|
1812
|
+
const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(sessionId);
|
|
1813
|
+
if (!promptCacheKey) return undefined;
|
|
1814
|
+
return `${baseUrl}:${model.id}:${promptCacheKey}`;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function getCodexWebSocketSessionState(
|
|
1818
|
+
sessionKey: string,
|
|
1819
|
+
providerSessionState: CodexProviderSessionState,
|
|
1820
|
+
): CodexWebSocketSessionState {
|
|
1821
|
+
const existing = providerSessionState.webSocketSessions.get(sessionKey);
|
|
1822
|
+
if (existing) return existing;
|
|
1823
|
+
const created: CodexWebSocketSessionState = {
|
|
1824
|
+
disableWebsocket: false,
|
|
1825
|
+
canAppend: false,
|
|
1826
|
+
fallbackCount: 0,
|
|
1827
|
+
prewarmed: false,
|
|
1828
|
+
stats: {
|
|
1829
|
+
fullContextRequests: 0,
|
|
1830
|
+
deltaRequests: 0,
|
|
1831
|
+
lastInputItems: 0,
|
|
1832
|
+
},
|
|
1833
|
+
};
|
|
1834
|
+
providerSessionState.webSocketSessions.set(sessionKey, created);
|
|
1835
|
+
return created;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function resetCodexWebSocketAppendState(state: CodexWebSocketSessionState): void {
|
|
1839
|
+
state.canAppend = false;
|
|
1840
|
+
state.lastRequest = undefined;
|
|
1841
|
+
state.lastResponseId = undefined;
|
|
1842
|
+
state.lastResponseItems = undefined;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function resetCodexSessionMetadata(state: CodexWebSocketSessionState): void {
|
|
1846
|
+
state.turnState = undefined;
|
|
1847
|
+
state.modelsEtag = undefined;
|
|
1848
|
+
state.reasoningIncluded = undefined;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
function recordCodexWebSocketFailure(state: CodexWebSocketSessionState, activateFallback: boolean): void {
|
|
1852
|
+
resetCodexWebSocketAppendState(state);
|
|
1853
|
+
state.connection?.close("fallback");
|
|
1854
|
+
state.connection = undefined;
|
|
1855
|
+
state.lastFallbackAt = Date.now();
|
|
1856
|
+
if (activateFallback && !state.disableWebsocket) {
|
|
1857
|
+
state.disableWebsocket = true;
|
|
1858
|
+
state.fallbackCount += 1;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function shouldUseCodexWebSocket(
|
|
1863
|
+
model: Model<"openai-codex-responses">,
|
|
1864
|
+
state: CodexWebSocketSessionState | undefined,
|
|
1865
|
+
preferWebsockets?: boolean,
|
|
1866
|
+
): boolean {
|
|
1867
|
+
if (!state || state.disableWebsocket) return false;
|
|
1868
|
+
if (preferWebsockets === false) return false;
|
|
1869
|
+
return isCodexWebSocketEnvEnabled() || preferWebsockets === true || model.preferWebsockets === true;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
export interface OpenAICodexTransportDetails {
|
|
1873
|
+
websocketPreferred: boolean;
|
|
1874
|
+
lastTransport?: CodexTransport;
|
|
1875
|
+
websocketDisabled: boolean;
|
|
1876
|
+
websocketConnected: boolean;
|
|
1877
|
+
fallbackCount: number;
|
|
1878
|
+
canAppend: boolean;
|
|
1879
|
+
prewarmed: boolean;
|
|
1880
|
+
hasSessionState: boolean;
|
|
1881
|
+
lastFallbackAt?: number;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function getCodexWebSocketStateForPublicSession(
|
|
1885
|
+
model: Model<"openai-codex-responses">,
|
|
1886
|
+
options:
|
|
1887
|
+
| {
|
|
1888
|
+
sessionId?: string;
|
|
1889
|
+
baseUrl?: string;
|
|
1890
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
1891
|
+
}
|
|
1892
|
+
| undefined,
|
|
1893
|
+
): CodexWebSocketSessionState | undefined {
|
|
1894
|
+
const baseUrl = options?.baseUrl || model.baseUrl || CODEX_BASE_URL;
|
|
1895
|
+
const providerSessionState = getCodexProviderSessionState(options?.providerSessionState);
|
|
1896
|
+
const publicSessionKey = getCodexPublicSessionKey(options?.sessionId, model, baseUrl);
|
|
1897
|
+
const privateSessionKey = publicSessionKey
|
|
1898
|
+
? providerSessionState?.webSocketPublicToPrivate.get(publicSessionKey)
|
|
1899
|
+
: undefined;
|
|
1900
|
+
return privateSessionKey ? providerSessionState?.webSocketSessions.get(privateSessionKey) : undefined;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
export function getOpenAICodexWebSocketDebugStats(
|
|
1904
|
+
model: Model<"openai-codex-responses">,
|
|
1905
|
+
options?: {
|
|
1906
|
+
sessionId?: string;
|
|
1907
|
+
baseUrl?: string;
|
|
1908
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
1909
|
+
},
|
|
1910
|
+
): OpenAICodexWebSocketDebugStats | undefined {
|
|
1911
|
+
const stats = getCodexWebSocketStateForPublicSession(model, options)?.stats;
|
|
1912
|
+
return stats ? { ...stats } : undefined;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
export function getOpenAICodexTransportDetails(
|
|
1916
|
+
model: Model<"openai-codex-responses">,
|
|
1917
|
+
options?: {
|
|
1918
|
+
sessionId?: string;
|
|
1919
|
+
baseUrl?: string;
|
|
1920
|
+
preferWebsockets?: boolean;
|
|
1921
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
1922
|
+
},
|
|
1923
|
+
): OpenAICodexTransportDetails {
|
|
1924
|
+
const websocketPreferred =
|
|
1925
|
+
options?.preferWebsockets === false
|
|
1926
|
+
? false
|
|
1927
|
+
: isCodexWebSocketEnvEnabled() || options?.preferWebsockets === true || model.preferWebsockets === true;
|
|
1928
|
+
const state = getCodexWebSocketStateForPublicSession(model, options);
|
|
1929
|
+
|
|
1930
|
+
return {
|
|
1931
|
+
websocketPreferred,
|
|
1932
|
+
lastTransport: state?.lastTransport,
|
|
1933
|
+
websocketDisabled: state?.disableWebsocket ?? false,
|
|
1934
|
+
websocketConnected: state?.connection?.isOpen() ?? false,
|
|
1935
|
+
fallbackCount: state?.fallbackCount ?? 0,
|
|
1936
|
+
canAppend: state?.canAppend ?? false,
|
|
1937
|
+
prewarmed: state?.prewarmed ?? false,
|
|
1938
|
+
hasSessionState: state !== undefined,
|
|
1939
|
+
lastFallbackAt: state?.lastFallbackAt,
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function buildAppendInput(
|
|
1944
|
+
previous: RequestBody | undefined,
|
|
1945
|
+
previousResponseItems: InputItem[] | undefined,
|
|
1946
|
+
current: RequestBody,
|
|
1947
|
+
): InputItem[] | null {
|
|
1948
|
+
if (!previous) return null;
|
|
1949
|
+
if (!Array.isArray(previous.input) || !Array.isArray(current.input)) return null;
|
|
1950
|
+
const previousWithoutInput = { ...previous, input: undefined };
|
|
1951
|
+
const currentWithoutInput = { ...current, input: undefined };
|
|
1952
|
+
if (JSON.stringify(previousWithoutInput) !== JSON.stringify(currentWithoutInput)) {
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
const baseline = [...previous.input, ...(previousResponseItems ?? [])];
|
|
1956
|
+
if (current.input.length <= baseline.length) return null;
|
|
1957
|
+
for (let index = 0; index < baseline.length; index += 1) {
|
|
1958
|
+
if (JSON.stringify(baseline[index]) !== JSON.stringify(current.input[index])) {
|
|
1959
|
+
return null;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
return current.input.slice(baseline.length) as InputItem[];
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function stripInputItemIds(items: Array<Record<string, unknown>>): InputItem[] {
|
|
1966
|
+
return items.map(item => {
|
|
1967
|
+
if (item.id == null) return item as InputItem;
|
|
1968
|
+
const { id: _id, ...rest } = item;
|
|
1969
|
+
return rest as InputItem;
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function recordCodexWebSocketRequestStats(
|
|
1974
|
+
state: CodexWebSocketSessionState | undefined,
|
|
1975
|
+
request: Record<string, unknown>,
|
|
1976
|
+
): void {
|
|
1977
|
+
if (!state) return;
|
|
1978
|
+
const input = request.input;
|
|
1979
|
+
state.stats.lastInputItems = Array.isArray(input) ? input.length : 0;
|
|
1980
|
+
if (typeof request.previous_response_id === "string" && request.previous_response_id.length > 0) {
|
|
1981
|
+
state.stats.deltaRequests += 1;
|
|
1982
|
+
state.stats.lastDeltaInputItems = state.stats.lastInputItems;
|
|
1983
|
+
state.stats.lastPreviousResponseId = request.previous_response_id;
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
state.stats.fullContextRequests += 1;
|
|
1987
|
+
state.stats.lastDeltaInputItems = undefined;
|
|
1988
|
+
state.stats.lastPreviousResponseId = undefined;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function buildCodexWebSocketRequest(
|
|
1992
|
+
requestBody: RequestBody,
|
|
1993
|
+
state: CodexWebSocketSessionState | undefined,
|
|
1994
|
+
): Record<string, unknown> {
|
|
1995
|
+
const appendInput = state?.canAppend
|
|
1996
|
+
? buildAppendInput(state.lastRequest, state.lastResponseItems, requestBody)
|
|
1997
|
+
: null;
|
|
1998
|
+
if (appendInput && appendInput.length > 0 && state?.lastResponseId) {
|
|
1999
|
+
const request = {
|
|
2000
|
+
type: "response.create",
|
|
2001
|
+
...requestBody,
|
|
2002
|
+
previous_response_id: state.lastResponseId,
|
|
2003
|
+
input: appendInput,
|
|
2004
|
+
};
|
|
2005
|
+
recordCodexWebSocketRequestStats(state, request);
|
|
2006
|
+
return request;
|
|
2007
|
+
}
|
|
2008
|
+
if (state?.canAppend) {
|
|
2009
|
+
logCodexDebug("codex websocket append reset", {
|
|
2010
|
+
hadTurnStateHeader: Boolean(state.turnState),
|
|
2011
|
+
hadModelsEtagHeader: Boolean(state.modelsEtag),
|
|
2012
|
+
});
|
|
2013
|
+
resetCodexWebSocketAppendState(state);
|
|
2014
|
+
resetCodexSessionMetadata(state);
|
|
2015
|
+
}
|
|
2016
|
+
const request = {
|
|
2017
|
+
type: "response.create",
|
|
2018
|
+
...requestBody,
|
|
2019
|
+
};
|
|
2020
|
+
recordCodexWebSocketRequestStats(state, request);
|
|
2021
|
+
return request;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function toWebSocketUrl(url: string): string {
|
|
2025
|
+
const parsed = new URL(url);
|
|
2026
|
+
if (parsed.protocol === "https:") {
|
|
2027
|
+
parsed.protocol = "wss:";
|
|
2028
|
+
} else if (parsed.protocol === "http:") {
|
|
2029
|
+
parsed.protocol = "ws:";
|
|
2030
|
+
}
|
|
2031
|
+
return parsed.toString();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
2035
|
+
const result: Record<string, string> = {};
|
|
2036
|
+
for (const [key, value] of headers.entries()) {
|
|
2037
|
+
result[key] = value;
|
|
2038
|
+
}
|
|
2039
|
+
return result;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
interface CodexWebSocketRequestTimeouts {
|
|
2043
|
+
idleTimeoutMs?: number;
|
|
2044
|
+
firstEventTimeoutMs?: number;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
interface CodexWebSocketConnectionOptions {
|
|
2048
|
+
onHandshakeHeaders?: (headers: Headers) => void;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
class CodexWebSocketConnection {
|
|
2052
|
+
#url: string;
|
|
2053
|
+
#headers: Record<string, string>;
|
|
2054
|
+
#onHandshakeHeaders?: (headers: Headers) => void;
|
|
2055
|
+
#socket: Bun.WebSocket | null = null;
|
|
2056
|
+
#queue: Array<Record<string, unknown> | Error | null> = [];
|
|
2057
|
+
#waiters: Array<() => void> = [];
|
|
2058
|
+
#connectPromise?: Promise<void>;
|
|
2059
|
+
#activeRequest = false;
|
|
2060
|
+
#streamObserver?: (event: RawSseEvent) => void;
|
|
2061
|
+
#heartbeatInterval: NodeJS.Timeout | undefined;
|
|
2062
|
+
#removePongListener?: () => void;
|
|
2063
|
+
#handshakeHeaders?: Headers;
|
|
2064
|
+
#debugResponseLog?: RequestDebugResponseLog;
|
|
2065
|
+
/**
|
|
2066
|
+
* Wall-clock of the most recent inbound activity on this socket — any
|
|
2067
|
+
* decoded message, any pong, or the moment the handshake completed. Used
|
|
2068
|
+
* by {@link isHealthyForReuse} so we don't write a continuation frame into
|
|
2069
|
+
* a TCP-open-but-server-evicted socket whose `readyState` still says OPEN.
|
|
2070
|
+
*/
|
|
2071
|
+
#lastInboundAt = 0;
|
|
2072
|
+
/** Wall-clock of the last heartbeat ping we issued; 0 if none yet. */
|
|
2073
|
+
#lastPingAt = 0;
|
|
2074
|
+
|
|
2075
|
+
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
|
|
2076
|
+
this.#url = url;
|
|
2077
|
+
this.#headers = headers;
|
|
2078
|
+
this.#onHandshakeHeaders = options.onHandshakeHeaders;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
isOpen(): boolean {
|
|
2082
|
+
return this.#socket?.readyState === WebSocket.OPEN;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* Stricter variant of {@link isOpen} for the connection-pool reuse gate.
|
|
2087
|
+
* Refuses sockets that have been silent past {@link CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS}.
|
|
2088
|
+
*
|
|
2089
|
+
* Bun's `WebSocket` does not always surface server-side eviction (no
|
|
2090
|
+
* `onclose`, no `onerror`), so a socket can sit in readyState OPEN long
|
|
2091
|
+
* after the upstream has dropped it. Reusing such a socket sends the next
|
|
2092
|
+
* `response.create` into a half-open write buffer and parks the reader
|
|
2093
|
+
* until the first-event / idle timeout fires (issue #1450). Forcing a
|
|
2094
|
+
* reconnect on any suspect socket trades a sub-second handshake for a
|
|
2095
|
+
* 60–300 s stall.
|
|
2096
|
+
*/
|
|
2097
|
+
isHealthyForReuse(): boolean {
|
|
2098
|
+
if (!this.isOpen()) return false;
|
|
2099
|
+
const maxIdleMs = getCodexWebSocketMaxIdleReuseMs();
|
|
2100
|
+
if (maxIdleMs <= 0) return true;
|
|
2101
|
+
// Initial connect sets #lastInboundAt; any later message or pong refreshes
|
|
2102
|
+
// it. A zero value means the field was never initialized, which itself is
|
|
2103
|
+
// a desync — treat as unhealthy.
|
|
2104
|
+
if (this.#lastInboundAt === 0) return false;
|
|
2105
|
+
return Date.now() - this.#lastInboundAt <= maxIdleMs;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
matchesAuth(headers: Record<string, string>): boolean {
|
|
2109
|
+
return this.#headers.authorization === headers.authorization;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
close(reason = "done"): void {
|
|
2113
|
+
if (
|
|
2114
|
+
this.#socket &&
|
|
2115
|
+
(this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING)
|
|
2116
|
+
) {
|
|
2117
|
+
this.#socket.close(1000, reason);
|
|
2118
|
+
}
|
|
2119
|
+
this.#socket = null;
|
|
2120
|
+
this.#stopHeartbeat();
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
async connect(signal?: AbortSignal): Promise<void> {
|
|
2124
|
+
if (this.isOpen()) return;
|
|
2125
|
+
if (this.#connectPromise) {
|
|
2126
|
+
logger.time("codexWs:awaitSharedHandshake");
|
|
2127
|
+
await this.#connectPromise;
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
2131
|
+
this.#connectPromise = promise;
|
|
2132
|
+
const socket = new (WebSocket as unknown as new (url: string, opts: Bun.WebSocketOptions) => Bun.WebSocket)(
|
|
2133
|
+
this.#url,
|
|
2134
|
+
{ headers: this.#headers },
|
|
2135
|
+
);
|
|
2136
|
+
socket.binaryType = "nodebuffer";
|
|
2137
|
+
this.#socket = socket;
|
|
2138
|
+
let settled = false;
|
|
2139
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
2140
|
+
const onAbort = () => {
|
|
2141
|
+
socket.close(1000, "aborted");
|
|
2142
|
+
if (!settled) {
|
|
2143
|
+
settled = true;
|
|
2144
|
+
reject(createCodexWebSocketTransportError("request was aborted"));
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
if (signal) {
|
|
2148
|
+
if (signal.aborted) {
|
|
2149
|
+
onAbort();
|
|
2150
|
+
} else {
|
|
2151
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
const clearPending = () => {
|
|
2155
|
+
if (timeout) clearTimeout(timeout);
|
|
2156
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
2157
|
+
};
|
|
2158
|
+
timeout = setTimeout(() => {
|
|
2159
|
+
socket.close(1000, "connect-timeout");
|
|
2160
|
+
if (!settled) {
|
|
2161
|
+
settled = true;
|
|
2162
|
+
reject(createCodexWebSocketTransportError("connection timeout"));
|
|
2163
|
+
}
|
|
2164
|
+
}, CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS);
|
|
2165
|
+
|
|
2166
|
+
socket.onopen = event => {
|
|
2167
|
+
if (!settled) {
|
|
2168
|
+
settled = true;
|
|
2169
|
+
clearPending();
|
|
2170
|
+
this.#lastInboundAt = Date.now();
|
|
2171
|
+
this.#captureHandshakeHeaders(socket, event);
|
|
2172
|
+
this.#startHeartbeat(socket);
|
|
2173
|
+
resolve();
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
socket.onerror = event => {
|
|
2177
|
+
const eventRecord = event as unknown as Record<string, unknown>;
|
|
2178
|
+
const detail =
|
|
2179
|
+
(typeof eventRecord.message === "string" && eventRecord.message) ||
|
|
2180
|
+
(eventRecord.error instanceof Error && eventRecord.error.message) ||
|
|
2181
|
+
String(event.type);
|
|
2182
|
+
const error = createCodexWebSocketTransportError(`websocket error: ${detail}`);
|
|
2183
|
+
if (!settled) {
|
|
2184
|
+
settled = true;
|
|
2185
|
+
clearPending();
|
|
2186
|
+
reject(error);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
this.#push(error);
|
|
2190
|
+
};
|
|
2191
|
+
socket.onclose = event => {
|
|
2192
|
+
this.#socket = null;
|
|
2193
|
+
this.#stopHeartbeat();
|
|
2194
|
+
if (!settled) {
|
|
2195
|
+
settled = true;
|
|
2196
|
+
clearPending();
|
|
2197
|
+
reject(createCodexWebSocketTransportError(`websocket closed before open (${event.code})`));
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
this.#push(createCodexWebSocketTransportError(`websocket closed (${event.code})`));
|
|
2201
|
+
this.#push(null);
|
|
2202
|
+
};
|
|
2203
|
+
socket.onmessage = event => {
|
|
2204
|
+
// Stamp inbound activity before parsing so even malformed frames refresh
|
|
2205
|
+
// the liveness clock — what matters for reuse health is that the upstream
|
|
2206
|
+
// is still talking to us, not that every frame is well-formed.
|
|
2207
|
+
this.#lastInboundAt = Date.now();
|
|
2208
|
+
this.#writeDebugWebSocketFrame(event.data);
|
|
2209
|
+
try {
|
|
2210
|
+
const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf-8");
|
|
2211
|
+
if (!text) return;
|
|
2212
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
2213
|
+
if (parsed.type === "error" && typeof parsed.error === "object" && parsed.error) {
|
|
2214
|
+
const inner = parsed.error as Record<string, unknown>;
|
|
2215
|
+
if (typeof parsed.code !== "string" && typeof inner.code === "string") {
|
|
2216
|
+
parsed.code = inner.code;
|
|
2217
|
+
}
|
|
2218
|
+
if (typeof parsed.message !== "string" && typeof inner.message === "string") {
|
|
2219
|
+
parsed.message = inner.message;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
notifyCodexWebSocketInbound(this.#streamObserver, parsed, text);
|
|
2223
|
+
this.#push(parsed);
|
|
2224
|
+
} catch (error) {
|
|
2225
|
+
notifyCodexWebSocketMalformed(this.#streamObserver, event.data, error);
|
|
2226
|
+
this.#push(createCodexWebSocketTransportError(String(error)));
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
logger.time("codexWs:awaitTcpHandshake");
|
|
2231
|
+
try {
|
|
2232
|
+
await promise;
|
|
2233
|
+
} finally {
|
|
2234
|
+
this.#connectPromise = undefined;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
async *streamRequest(
|
|
2239
|
+
request: Record<string, unknown>,
|
|
2240
|
+
timeouts: CodexWebSocketRequestTimeouts,
|
|
2241
|
+
signal?: AbortSignal,
|
|
2242
|
+
onSseEvent?: (event: RawSseEvent) => void,
|
|
2243
|
+
): AsyncGenerator<Record<string, unknown>> {
|
|
2244
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
2245
|
+
throw createCodexWebSocketTransportError("websocket connection is unavailable");
|
|
2246
|
+
}
|
|
2247
|
+
if (this.#activeRequest) {
|
|
2248
|
+
throw createCodexWebSocketTransportError("websocket request already in progress");
|
|
2249
|
+
}
|
|
2250
|
+
this.#activeRequest = true;
|
|
2251
|
+
this.#streamObserver = onSseEvent;
|
|
2252
|
+
// Drain any non-error frames left over from a prior request before sending.
|
|
2253
|
+
// `processCodexResponseStream` breaks its `for-await` on the terminal event,
|
|
2254
|
+
// which interrupts our generator at `yield next` (the post-yield `break`
|
|
2255
|
+
// never runs). Any frame that landed between the consumer's break and the
|
|
2256
|
+
// generator's `finally` lingers in `#queue` and would otherwise become the
|
|
2257
|
+
// first frame of THIS request — a stale `response.completed` would end the
|
|
2258
|
+
// turn immediately with empty output, and a stale non-progress frame would
|
|
2259
|
+
// flip `sawFirstEvent` and silently downgrade the first-event timeout to
|
|
2260
|
+
// the longer idle timeout. Transport errors are preserved so we surface
|
|
2261
|
+
// the death signal instead of writing into a dead socket.
|
|
2262
|
+
this.#dropStaleFrames();
|
|
2263
|
+
const onAbort = () => {
|
|
2264
|
+
this.close("aborted");
|
|
2265
|
+
this.#push(createCodexWebSocketTransportError("request was aborted"));
|
|
2266
|
+
};
|
|
2267
|
+
if (signal) {
|
|
2268
|
+
if (signal.aborted) {
|
|
2269
|
+
onAbort();
|
|
2270
|
+
} else {
|
|
2271
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
try {
|
|
2276
|
+
const debugSession = isRequestDebugEnabled()
|
|
2277
|
+
? await createRequestDebugSession({
|
|
2278
|
+
protocol: "websocket",
|
|
2279
|
+
method: "POST",
|
|
2280
|
+
url: this.#url,
|
|
2281
|
+
headers: this.#headers,
|
|
2282
|
+
body: request,
|
|
2283
|
+
})
|
|
2284
|
+
: undefined;
|
|
2285
|
+
this.#debugResponseLog = debugSession
|
|
2286
|
+
? await debugSession.openResponseLog("WebSocket 101 Switching Protocols", this.#handshakeHeaders)
|
|
2287
|
+
: undefined;
|
|
2288
|
+
|
|
2289
|
+
const requestPayload = JSON.stringify(request);
|
|
2290
|
+
notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
|
|
2291
|
+
try {
|
|
2292
|
+
this.#socket.send(requestPayload);
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
throw createCodexWebSocketTransportError(
|
|
2295
|
+
`websocket send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
let sawFirstEvent = false;
|
|
2299
|
+
const { idleTimeoutMs, firstEventTimeoutMs } = timeouts;
|
|
2300
|
+
let lastProgressAt = Date.now();
|
|
2301
|
+
let lastProgressEventType: string | undefined;
|
|
2302
|
+
let lastEventAt = lastProgressAt;
|
|
2303
|
+
let lastEventType: string | undefined;
|
|
2304
|
+
while (true) {
|
|
2305
|
+
let timeoutMs: number | undefined;
|
|
2306
|
+
let timeoutReason: string;
|
|
2307
|
+
if (sawFirstEvent) {
|
|
2308
|
+
timeoutReason = createCodexWebSocketTimeoutMessage("idle timeout waiting for websocket", {
|
|
2309
|
+
lastEventAt,
|
|
2310
|
+
lastEventType,
|
|
2311
|
+
lastProgressAt,
|
|
2312
|
+
lastProgressEventType,
|
|
2313
|
+
});
|
|
2314
|
+
if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) {
|
|
2315
|
+
timeoutMs = idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
2316
|
+
if (timeoutMs <= 0) {
|
|
2317
|
+
logCodexDebug("codex websocket idle timeout", {
|
|
2318
|
+
lastEventType,
|
|
2319
|
+
lastProgressEventType,
|
|
2320
|
+
msSinceLastEvent: Date.now() - lastEventAt,
|
|
2321
|
+
msSinceLastProgress: Date.now() - lastProgressAt,
|
|
2322
|
+
});
|
|
2323
|
+
throw createCodexWebSocketTransportError(timeoutReason);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
} else {
|
|
2327
|
+
timeoutReason = createCodexWebSocketTimeoutMessage("timeout waiting for first websocket event", {
|
|
2328
|
+
lastEventAt,
|
|
2329
|
+
lastEventType,
|
|
2330
|
+
lastProgressAt,
|
|
2331
|
+
lastProgressEventType,
|
|
2332
|
+
});
|
|
2333
|
+
if (firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0) {
|
|
2334
|
+
timeoutMs = firstEventTimeoutMs;
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
const next = await this.#nextMessage(timeoutMs, timeoutReason);
|
|
2338
|
+
if (next instanceof Error) {
|
|
2339
|
+
throw next;
|
|
2340
|
+
}
|
|
2341
|
+
if (next === null) {
|
|
2342
|
+
throw createCodexWebSocketTransportError("websocket closed before response completion");
|
|
2343
|
+
}
|
|
2344
|
+
sawFirstEvent = true;
|
|
2345
|
+
const eventType = typeof next.type === "string" ? next.type : "";
|
|
2346
|
+
lastEventAt = Date.now();
|
|
2347
|
+
lastEventType = eventType || undefined;
|
|
2348
|
+
if (isCodexStreamProgressEvent(next)) {
|
|
2349
|
+
lastProgressAt = lastEventAt;
|
|
2350
|
+
lastProgressEventType = lastEventType;
|
|
2351
|
+
}
|
|
2352
|
+
yield next;
|
|
2353
|
+
if (
|
|
2354
|
+
eventType === "response.completed" ||
|
|
2355
|
+
eventType === "response.done" ||
|
|
2356
|
+
eventType === "response.incomplete" ||
|
|
2357
|
+
eventType === "response.failed" ||
|
|
2358
|
+
eventType === "error"
|
|
2359
|
+
) {
|
|
2360
|
+
break;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
} finally {
|
|
2364
|
+
this.#activeRequest = false;
|
|
2365
|
+
this.#streamObserver = undefined;
|
|
2366
|
+
if (signal) {
|
|
2367
|
+
signal.removeEventListener("abort", onAbort);
|
|
2368
|
+
}
|
|
2369
|
+
const debugResponseLog = this.#debugResponseLog;
|
|
2370
|
+
this.#debugResponseLog = undefined;
|
|
2371
|
+
await debugResponseLog?.close();
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
#captureHandshakeHeaders(socket: Bun.WebSocket, openEvent?: Event): void {
|
|
2376
|
+
const headers = extractCodexWebSocketHandshakeHeaders(socket, openEvent);
|
|
2377
|
+
if (!headers) return;
|
|
2378
|
+
this.#handshakeHeaders = headers;
|
|
2379
|
+
this.#onHandshakeHeaders?.(headers);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
#writeDebugWebSocketFrame(data: unknown): void {
|
|
2383
|
+
const log = this.#debugResponseLog;
|
|
2384
|
+
if (!log) return;
|
|
2385
|
+
if (typeof data === "string") {
|
|
2386
|
+
log.write(data);
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
if (data instanceof Uint8Array) {
|
|
2390
|
+
log.write(data);
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
if (data instanceof ArrayBuffer) {
|
|
2394
|
+
log.write(new Uint8Array(data));
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
log.write(String(data));
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
#startHeartbeat(socket: Bun.WebSocket): void {
|
|
2401
|
+
this.#stopHeartbeat();
|
|
2402
|
+
const intervalMs = getCodexWebSocketPingIntervalMs();
|
|
2403
|
+
if (intervalMs <= 0) return;
|
|
2404
|
+
|
|
2405
|
+
this.#lastPingAt = 0;
|
|
2406
|
+
const socketEventTarget = socket as EventTarget;
|
|
2407
|
+
const onPong = () => {
|
|
2408
|
+
// Pongs are inbound activity — refresh the reuse-health clock so a quiet
|
|
2409
|
+
// but ping-responsive socket stays trustworthy across requests.
|
|
2410
|
+
this.#lastInboundAt = Date.now();
|
|
2411
|
+
};
|
|
2412
|
+
if (
|
|
2413
|
+
typeof socketEventTarget.addEventListener === "function" &&
|
|
2414
|
+
typeof socketEventTarget.removeEventListener === "function"
|
|
2415
|
+
) {
|
|
2416
|
+
socketEventTarget.addEventListener("pong", onPong);
|
|
2417
|
+
this.#removePongListener = () => socketEventTarget.removeEventListener("pong", onPong);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
this.#heartbeatInterval = setInterval(() => {
|
|
2421
|
+
if (this.#socket !== socket || socket.readyState !== WebSocket.OPEN) {
|
|
2422
|
+
this.#stopHeartbeat();
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
// Fail-closed on missing pongs even when no pong has ever been observed.
|
|
2426
|
+
// The previous `#observedPong &&` guard disabled the timeout entirely on
|
|
2427
|
+
// runtimes where Bun does not surface a `pong` event for our outgoing
|
|
2428
|
+
// pings (issue #1450) — letting truly dead sockets sail through the
|
|
2429
|
+
// pool until the per-request first-event / idle timeout (60–300 s)
|
|
2430
|
+
// finally fired. Instead, trigger on inbound silence: if we sent a
|
|
2431
|
+
// ping at least `pongTimeoutMs` ago and have received no traffic of
|
|
2432
|
+
// any kind (data frame or pong) since, the socket is unhealthy.
|
|
2433
|
+
const pongTimeoutMs = getCodexWebSocketPongTimeoutMs();
|
|
2434
|
+
if (
|
|
2435
|
+
pongTimeoutMs > 0 &&
|
|
2436
|
+
this.#lastPingAt > 0 &&
|
|
2437
|
+
this.#lastPingAt > this.#lastInboundAt &&
|
|
2438
|
+
Date.now() - this.#lastPingAt > pongTimeoutMs
|
|
2439
|
+
) {
|
|
2440
|
+
this.#failQueue(createCodexWebSocketTransportError("websocket pong timeout"), "pong-timeout");
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
if (typeof socket.ping !== "function") {
|
|
2444
|
+
this.#stopHeartbeat();
|
|
2445
|
+
return;
|
|
2446
|
+
}
|
|
2447
|
+
try {
|
|
2448
|
+
socket.ping();
|
|
2449
|
+
this.#lastPingAt = Date.now();
|
|
2450
|
+
} catch (error) {
|
|
2451
|
+
this.#failQueue(
|
|
2452
|
+
createCodexWebSocketTransportError(
|
|
2453
|
+
`websocket ping failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2454
|
+
),
|
|
2455
|
+
"ping-failed",
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
}, intervalMs);
|
|
2459
|
+
this.#heartbeatInterval.unref();
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
#stopHeartbeat(): void {
|
|
2463
|
+
if (this.#heartbeatInterval) {
|
|
2464
|
+
clearInterval(this.#heartbeatInterval);
|
|
2465
|
+
this.#heartbeatInterval = undefined;
|
|
2466
|
+
}
|
|
2467
|
+
if (this.#removePongListener) {
|
|
2468
|
+
this.#removePongListener();
|
|
2469
|
+
this.#removePongListener = undefined;
|
|
2470
|
+
}
|
|
2471
|
+
this.#lastPingAt = 0;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
#failQueue(error: Error, closeReason: string): void {
|
|
2475
|
+
logCodexDebug("codex websocket transport failure", { error: error.message, closeReason });
|
|
2476
|
+
this.#queue.length = 0;
|
|
2477
|
+
this.#queue.push(error);
|
|
2478
|
+
this.close(closeReason);
|
|
2479
|
+
this.#wakeWaiters();
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Discard data frames from a previous request that remained in `#queue`
|
|
2484
|
+
* after the consumer broke out on the terminal event. Preserves any queued
|
|
2485
|
+
* transport error (from `onerror` / `onclose` / `#failQueue`) so the next
|
|
2486
|
+
* `#nextMessage` surfaces the death signal instead of waiting it out.
|
|
2487
|
+
*
|
|
2488
|
+
* Returns the number of frames dropped (test/debug visibility only).
|
|
2489
|
+
*/
|
|
2490
|
+
#dropStaleFrames(): number {
|
|
2491
|
+
if (this.#queue.length === 0) return 0;
|
|
2492
|
+
const surviving = this.#queue.filter(item => item instanceof Error);
|
|
2493
|
+
const dropped = this.#queue.length - surviving.length;
|
|
2494
|
+
if (dropped === 0) return 0;
|
|
2495
|
+
this.#queue.length = 0;
|
|
2496
|
+
for (const item of surviving) this.#queue.push(item);
|
|
2497
|
+
logCodexDebug("codex websocket dropped stale frames before request", { dropped });
|
|
2498
|
+
return dropped;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
#wakeWaiters(): void {
|
|
2502
|
+
for (;;) {
|
|
2503
|
+
const waiter = this.#waiters.shift();
|
|
2504
|
+
if (!waiter) break;
|
|
2505
|
+
waiter();
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
#push(item: Record<string, unknown> | Error | null): void {
|
|
2510
|
+
if (item instanceof Error) {
|
|
2511
|
+
if (!(this.#queue[0] instanceof Error)) {
|
|
2512
|
+
this.#queue.length = 0;
|
|
2513
|
+
}
|
|
2514
|
+
this.#queue.push(item);
|
|
2515
|
+
this.#wakeWaiters();
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (item !== null && this.#queue.length >= getCodexWebSocketMessageQueueCapacity()) {
|
|
2519
|
+
this.#failQueue(
|
|
2520
|
+
createCodexWebSocketTransportError(
|
|
2521
|
+
`websocket message queue exceeded ${getCodexWebSocketMessageQueueCapacity()} items`,
|
|
2522
|
+
),
|
|
2523
|
+
"queue-overflow",
|
|
2524
|
+
);
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
this.#queue.push(item);
|
|
2528
|
+
const waiter = this.#waiters.shift();
|
|
2529
|
+
if (waiter) waiter();
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
async #nextMessage(
|
|
2533
|
+
timeoutMs: number | undefined,
|
|
2534
|
+
timeoutReason: string,
|
|
2535
|
+
): Promise<Record<string, unknown> | Error | null> {
|
|
2536
|
+
while (this.#queue.length === 0) {
|
|
2537
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
2538
|
+
this.#waiters.push(resolve);
|
|
2539
|
+
let timedOut = false;
|
|
2540
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
2541
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
2542
|
+
timeout = setTimeout(() => {
|
|
2543
|
+
timedOut = true;
|
|
2544
|
+
const waiterIndex = this.#waiters.indexOf(resolve);
|
|
2545
|
+
if (waiterIndex >= 0) {
|
|
2546
|
+
this.#waiters.splice(waiterIndex, 1);
|
|
2547
|
+
}
|
|
2548
|
+
resolve();
|
|
2549
|
+
}, timeoutMs);
|
|
2550
|
+
}
|
|
2551
|
+
await promise;
|
|
2552
|
+
if (timeout) clearTimeout(timeout);
|
|
2553
|
+
if (timedOut && this.#queue.length === 0) {
|
|
2554
|
+
return createCodexWebSocketTransportError(timeoutReason);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
return this.#queue.shift() ?? null;
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
async function getOrCreateCodexWebSocketConnection(
|
|
2562
|
+
state: CodexWebSocketSessionState,
|
|
2563
|
+
url: string,
|
|
2564
|
+
headers: Headers,
|
|
2565
|
+
signal?: AbortSignal,
|
|
2566
|
+
): Promise<CodexWebSocketConnection> {
|
|
2567
|
+
const headerRecord = headersToRecord(headers);
|
|
2568
|
+
if (state.connection?.isOpen()) {
|
|
2569
|
+
if (!state.connection.matchesAuth(headerRecord)) {
|
|
2570
|
+
state.connection.close("token-refresh");
|
|
2571
|
+
resetCodexWebSocketAppendState(state);
|
|
2572
|
+
} else if (state.connection.isHealthyForReuse()) {
|
|
2573
|
+
logger.time("codexWs:reuseOpenSocket");
|
|
2574
|
+
return state.connection;
|
|
2575
|
+
} else {
|
|
2576
|
+
// Open in readyState but no inbound traffic recently — likely server-
|
|
2577
|
+
// evicted (issue #1450). Force a fresh handshake instead of writing
|
|
2578
|
+
// `response.create` into a half-open buffer and waiting out the
|
|
2579
|
+
// first-event timeout. Drop append state because the new socket
|
|
2580
|
+
// won't carry the prior `previous_response_id` context.
|
|
2581
|
+
logCodexDebug("codex websocket reuse rejected by health check", {});
|
|
2582
|
+
state.connection.close("stale-reuse");
|
|
2583
|
+
resetCodexWebSocketAppendState(state);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
state.connection?.close("reconnect");
|
|
2587
|
+
resetCodexWebSocketAppendState(state);
|
|
2588
|
+
logger.time("codexWs:newSocket");
|
|
2589
|
+
state.connection = new CodexWebSocketConnection(url, headerRecord, {
|
|
2590
|
+
onHandshakeHeaders: handshakeHeaders => {
|
|
2591
|
+
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
|
|
2592
|
+
},
|
|
2593
|
+
});
|
|
2594
|
+
await state.connection.connect(signal);
|
|
2595
|
+
return state.connection;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
async function openCodexSseEventStream(
|
|
2599
|
+
url: string,
|
|
2600
|
+
requestHeaders: Record<string, string> | undefined,
|
|
2601
|
+
accountId: string,
|
|
2602
|
+
apiKey: string,
|
|
2603
|
+
sessionId: string | undefined,
|
|
2604
|
+
body: RequestBody,
|
|
2605
|
+
state: CodexWebSocketSessionState | undefined,
|
|
2606
|
+
signal?: AbortSignal,
|
|
2607
|
+
onSseEvent?: OpenAICodexResponsesOptions["onSseEvent"],
|
|
2608
|
+
fetchOverride?: FetchImpl,
|
|
2609
|
+
): Promise<AsyncGenerator<Record<string, unknown>>> {
|
|
2610
|
+
const headers = createCodexHeaders(requestHeaders, accountId, apiKey, sessionId, "sse", state);
|
|
2611
|
+
logCodexDebug("codex request", {
|
|
2612
|
+
url,
|
|
2613
|
+
model: body.model,
|
|
2614
|
+
headers: redactHeaders(headers),
|
|
2615
|
+
sentTurnStateHeader: headers.has(X_CODEX_TURN_STATE_HEADER),
|
|
2616
|
+
sentModelsEtagHeader: headers.has(X_MODELS_ETAG_HEADER),
|
|
2617
|
+
});
|
|
2618
|
+
const response = await fetchWithRetry(url, {
|
|
2619
|
+
method: "POST",
|
|
2620
|
+
headers,
|
|
2621
|
+
body: JSON.stringify(body),
|
|
2622
|
+
signal,
|
|
2623
|
+
maxAttempts: CODEX_MAX_RETRIES + 1,
|
|
2624
|
+
defaultDelayMs: attempt => CODEX_RETRY_DELAY_MS * (attempt + 1),
|
|
2625
|
+
maxDelayMs: CODEX_RATE_LIMIT_BUDGET_MS,
|
|
2626
|
+
fetch: fetchOverride,
|
|
2627
|
+
});
|
|
2628
|
+
logCodexDebug("codex response", {
|
|
2629
|
+
url: response.url,
|
|
2630
|
+
status: response.status,
|
|
2631
|
+
statusText: response.statusText,
|
|
2632
|
+
contentType: response.headers.get("content-type") || null,
|
|
2633
|
+
cfRay: response.headers.get("cf-ray") || null,
|
|
2634
|
+
});
|
|
2635
|
+
updateCodexSessionMetadataFromHeaders(state, response.headers);
|
|
2636
|
+
if (!response.ok) {
|
|
2637
|
+
const info = await parseCodexError(response);
|
|
2638
|
+
const error = new Error(info.friendlyMessage || info.message);
|
|
2639
|
+
(error as { headers?: Headers; status?: number }).headers = response.headers;
|
|
2640
|
+
(error as { headers?: Headers; status?: number }).status = response.status;
|
|
2641
|
+
throw error;
|
|
2642
|
+
}
|
|
2643
|
+
if (!response.body) {
|
|
2644
|
+
throw new Error("No response body");
|
|
2645
|
+
}
|
|
2646
|
+
return readSseJson<Record<string, unknown>>(response.body, signal, event =>
|
|
2647
|
+
onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, undefined),
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
async function openCodexWebSocketEventStream(
|
|
2652
|
+
url: string,
|
|
2653
|
+
headers: Headers,
|
|
2654
|
+
request: Record<string, unknown>,
|
|
2655
|
+
state: CodexWebSocketSessionState,
|
|
2656
|
+
timeouts: CodexWebSocketRequestTimeouts,
|
|
2657
|
+
signal?: AbortSignal,
|
|
2658
|
+
onSseEvent?: (event: RawSseEvent) => void,
|
|
2659
|
+
): Promise<AsyncGenerator<Record<string, unknown>>> {
|
|
2660
|
+
const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal);
|
|
2661
|
+
return connection.streamRequest(request, timeouts, signal, onSseEvent);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
function createCodexHeaders(
|
|
2665
|
+
initHeaders: Record<string, string> | undefined,
|
|
2666
|
+
accountId: string,
|
|
2667
|
+
accessToken: string,
|
|
2668
|
+
sessionId?: string,
|
|
2669
|
+
transport: CodexTransport = "sse",
|
|
2670
|
+
state?: CodexWebSocketSessionState,
|
|
2671
|
+
): Headers {
|
|
2672
|
+
const headers = new Headers(initHeaders ?? {});
|
|
2673
|
+
headers.delete("x-api-key");
|
|
2674
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
2675
|
+
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
|
|
2676
|
+
const betaHeader =
|
|
2677
|
+
transport === "websocket"
|
|
2678
|
+
? OPENAI_HEADER_VALUES.BETA_RESPONSES_WEBSOCKETS_V2
|
|
2679
|
+
: OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
2680
|
+
headers.delete(OPENAI_HEADERS.BETA);
|
|
2681
|
+
headers.delete("openai-beta");
|
|
2682
|
+
headers.set(OPENAI_HEADERS.BETA, betaHeader);
|
|
2683
|
+
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
|
2684
|
+
headers.set("User-Agent", getCodexUserAgent());
|
|
2685
|
+
if (sessionId) {
|
|
2686
|
+
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
|
|
2687
|
+
headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
|
|
2688
|
+
headers.set("x-client-request-id", sessionId);
|
|
2689
|
+
} else {
|
|
2690
|
+
headers.delete(OPENAI_HEADERS.CONVERSATION_ID);
|
|
2691
|
+
headers.delete(OPENAI_HEADERS.SESSION_ID);
|
|
2692
|
+
}
|
|
2693
|
+
if (state?.turnState) {
|
|
2694
|
+
headers.set(X_CODEX_TURN_STATE_HEADER, state.turnState);
|
|
2695
|
+
} else {
|
|
2696
|
+
headers.delete(X_CODEX_TURN_STATE_HEADER);
|
|
2697
|
+
}
|
|
2698
|
+
if (state?.modelsEtag) {
|
|
2699
|
+
headers.set(X_MODELS_ETAG_HEADER, state.modelsEtag);
|
|
2700
|
+
} else {
|
|
2701
|
+
headers.delete(X_MODELS_ETAG_HEADER);
|
|
2702
|
+
}
|
|
2703
|
+
if (transport === "sse") {
|
|
2704
|
+
headers.set("accept", "text/event-stream");
|
|
2705
|
+
headers.set("content-type", "application/json");
|
|
2706
|
+
} else {
|
|
2707
|
+
headers.delete("accept");
|
|
2708
|
+
headers.delete("content-type");
|
|
2709
|
+
}
|
|
2710
|
+
return headers;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function logCodexDebug(message: string, details?: Record<string, unknown>): void {
|
|
2714
|
+
if (!CODEX_DEBUG) return;
|
|
2715
|
+
logger.debug(`[codex] ${message}`, details ?? {});
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
function redactHeaders(headers: Headers): Record<string, string> {
|
|
2719
|
+
const redacted: Record<string, string> = {};
|
|
2720
|
+
for (const [key, value] of headers.entries()) {
|
|
2721
|
+
const lower = key.toLowerCase();
|
|
2722
|
+
if (lower === "authorization") {
|
|
2723
|
+
redacted[key] = "Bearer [redacted]";
|
|
2724
|
+
continue;
|
|
2725
|
+
}
|
|
2726
|
+
if (
|
|
2727
|
+
lower.includes("account") ||
|
|
2728
|
+
lower.includes("session") ||
|
|
2729
|
+
lower.includes("conversation") ||
|
|
2730
|
+
lower === "cookie"
|
|
2731
|
+
) {
|
|
2732
|
+
redacted[key] = "[redacted]";
|
|
2733
|
+
continue;
|
|
2734
|
+
}
|
|
2735
|
+
redacted[key] = value;
|
|
2736
|
+
}
|
|
2737
|
+
return redacted;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
|
|
2741
|
+
const raw = baseUrl && baseUrl.trim().length > 0 ? baseUrl : CODEX_BASE_URL;
|
|
2742
|
+
const normalized = raw.replace(/\/+$/, "");
|
|
2743
|
+
if (normalized.endsWith("/codex/responses")) return normalized;
|
|
2744
|
+
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
|
|
2745
|
+
return `${normalized}/codex/responses`;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function getAccountId(accessToken: string): string {
|
|
2749
|
+
const accountId = getCodexAccountId(accessToken);
|
|
2750
|
+
if (!accountId) {
|
|
2751
|
+
throw new Error("Failed to extract accountId from token");
|
|
2752
|
+
}
|
|
2753
|
+
return accountId;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput {
|
|
2757
|
+
const messages: ResponseInput = [];
|
|
2758
|
+
|
|
2759
|
+
const normalizeToolCallId = (id: string): string => {
|
|
2760
|
+
if (!id.includes("|")) return id;
|
|
2761
|
+
const [callId, itemId] = id.split("|");
|
|
2762
|
+
const sanitizedCallId = callId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2763
|
+
let sanitizedItemId = itemId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2764
|
+
if (!sanitizedItemId.startsWith("fc")) {
|
|
2765
|
+
sanitizedItemId = `fc_${sanitizedItemId}`;
|
|
2766
|
+
}
|
|
2767
|
+
let normalizedCallId = sanitizedCallId.length > 64 ? sanitizedCallId.slice(0, 64) : sanitizedCallId;
|
|
2768
|
+
let normalizedItemId = sanitizedItemId.length > 64 ? sanitizedItemId.slice(0, 64) : sanitizedItemId;
|
|
2769
|
+
normalizedCallId = normalizedCallId.replace(/_+$/, "");
|
|
2770
|
+
normalizedItemId = normalizedItemId.replace(/_+$/, "");
|
|
2771
|
+
return `${normalizedCallId}|${normalizedItemId}`;
|
|
2772
|
+
};
|
|
2773
|
+
|
|
2774
|
+
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
|
2775
|
+
let msgIndex = 0;
|
|
2776
|
+
// Track call_ids that originated as custom tool calls so paired tool-result
|
|
2777
|
+
// messages can be replayed as `custom_tool_call_output` rather than
|
|
2778
|
+
// `function_call_output` (OpenAI rejects mismatched pairs).
|
|
2779
|
+
const customCallIds = new Set<string>();
|
|
2780
|
+
const knownCallIds = new Set<string>();
|
|
2781
|
+
|
|
2782
|
+
for (const msg of transformedMessages) {
|
|
2783
|
+
if (msg.role === "user" || msg.role === "developer") {
|
|
2784
|
+
const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
|
|
2785
|
+
const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider) as
|
|
2786
|
+
| Array<ResponseInput[number]>
|
|
2787
|
+
| undefined;
|
|
2788
|
+
if (historyItems) {
|
|
2789
|
+
for (const item of historyItems) {
|
|
2790
|
+
const maybe = item as { type?: string; call_id?: string };
|
|
2791
|
+
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
|
|
2792
|
+
customCallIds.add(maybe.call_id);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
messages.push(...historyItems);
|
|
2796
|
+
msgIndex += 1;
|
|
2797
|
+
continue;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
const normalizedContent = normalizeInputMessageContent(model, msg.content);
|
|
2801
|
+
if (normalizedContent.length === 0) continue;
|
|
2802
|
+
messages.push({ role: msg.role, content: normalizedContent });
|
|
2803
|
+
msgIndex += 1;
|
|
2804
|
+
continue;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
if (msg.role === "assistant") {
|
|
2808
|
+
const assistantMsg = msg as AssistantMessage;
|
|
2809
|
+
const providerPayload = getOpenAIResponsesHistoryPayload(
|
|
2810
|
+
assistantMsg.providerPayload,
|
|
2811
|
+
model.provider,
|
|
2812
|
+
assistantMsg.provider,
|
|
2813
|
+
);
|
|
2814
|
+
const historyItems = providerPayload?.items as Array<ResponseInput[number]> | undefined;
|
|
2815
|
+
if (historyItems) {
|
|
2816
|
+
for (const item of historyItems) {
|
|
2817
|
+
const maybe = item as { type?: string; call_id?: string };
|
|
2818
|
+
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
|
|
2819
|
+
customCallIds.add(maybe.call_id);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
if (providerPayload?.dt) {
|
|
2823
|
+
messages.push(...historyItems);
|
|
2824
|
+
} else {
|
|
2825
|
+
messages.splice(0, messages.length, ...historyItems);
|
|
2826
|
+
// Keep customCallIds from the pre-splice state since historyItems may re-introduce them.
|
|
2827
|
+
}
|
|
2828
|
+
msgIndex += 1;
|
|
2829
|
+
continue;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
const outputItems = convertResponsesAssistantMessage(
|
|
2833
|
+
msg as AssistantMessage,
|
|
2834
|
+
model,
|
|
2835
|
+
msgIndex,
|
|
2836
|
+
knownCallIds,
|
|
2837
|
+
true,
|
|
2838
|
+
customCallIds,
|
|
2839
|
+
);
|
|
2840
|
+
if (outputItems.length > 0) {
|
|
2841
|
+
messages.push(...outputItems);
|
|
2842
|
+
}
|
|
2843
|
+
msgIndex += 1;
|
|
2844
|
+
continue;
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
if (msg.role === "toolResult") {
|
|
2848
|
+
appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
msgIndex += 1;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
return messages;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function normalizeInputMessageContent(
|
|
2858
|
+
model: Model<"openai-codex-responses">,
|
|
2859
|
+
content: string | Array<{ type: "text"; text: string } | { type: "image"; mimeType: string; data: string }>,
|
|
2860
|
+
): ResponseInputContent[] {
|
|
2861
|
+
if (typeof content === "string") {
|
|
2862
|
+
if (!content || content.trim() === "") return [];
|
|
2863
|
+
return [{ type: "input_text", text: content.toWellFormed() }];
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
return convertResponsesInputContent(content, model.input.includes("image")) ?? [];
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/** @internal Exported for tests. */
|
|
2870
|
+
export { convertMessages as convertCodexResponsesMessages };
|
|
2871
|
+
|
|
2872
|
+
/**
|
|
2873
|
+
* Whether this Codex-backend model should get the custom-tool grammar
|
|
2874
|
+
* variant for `apply_patch`. codex-rs uses a single serializer for both
|
|
2875
|
+
* the public Responses endpoint and `chatgpt.com/backend-api`, so the
|
|
2876
|
+
* backend already accepts `{type: "custom"}` tools in production. The
|
|
2877
|
+
* generated model catalog sets `applyPatchToolType` for first-party GPT-5
|
|
2878
|
+
* Codex models; this runtime path only consumes that metadata.
|
|
2879
|
+
*/
|
|
2880
|
+
function supportsFreeformApplyPatchCodex(model: Model<"openai-codex-responses">): boolean {
|
|
2881
|
+
return model.applyPatchToolType === "freeform";
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
type CodexToolPayload =
|
|
2885
|
+
| {
|
|
2886
|
+
type: "function";
|
|
2887
|
+
name: string;
|
|
2888
|
+
description: string;
|
|
2889
|
+
parameters: Record<string, unknown>;
|
|
2890
|
+
strict?: boolean;
|
|
2891
|
+
}
|
|
2892
|
+
| {
|
|
2893
|
+
type: "custom";
|
|
2894
|
+
name: string;
|
|
2895
|
+
description: string;
|
|
2896
|
+
format: { type: "grammar"; syntax: "lark" | "regex"; definition: string };
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2899
|
+
/** @internal Exported for tests. */
|
|
2900
|
+
export function convertOpenAICodexResponsesTools(
|
|
2901
|
+
tools: Tool[],
|
|
2902
|
+
model: Model<"openai-codex-responses">,
|
|
2903
|
+
): CodexToolPayload[] {
|
|
2904
|
+
const allowFreeform = supportsFreeformApplyPatchCodex(model);
|
|
2905
|
+
return tools.map((tool): CodexToolPayload => {
|
|
2906
|
+
if (allowFreeform && tool.customFormat) {
|
|
2907
|
+
return {
|
|
2908
|
+
type: "custom",
|
|
2909
|
+
name: tool.customWireName ?? tool.name,
|
|
2910
|
+
description: tool.description || "",
|
|
2911
|
+
format: {
|
|
2912
|
+
type: "grammar",
|
|
2913
|
+
syntax: tool.customFormat.syntax,
|
|
2914
|
+
definition: compactGrammarDefinition(tool.customFormat.syntax, tool.customFormat.definition),
|
|
2915
|
+
},
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
const strict = !!(!NO_STRICT && tool.strict);
|
|
2919
|
+
const baseParameters = sanitizeSchemaForOpenAIResponses(toolWireSchema(tool));
|
|
2920
|
+
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
|
|
2921
|
+
return {
|
|
2922
|
+
type: "function",
|
|
2923
|
+
name: tool.name,
|
|
2924
|
+
description: tool.description || "",
|
|
2925
|
+
parameters,
|
|
2926
|
+
...(effectiveStrict && { strict: true }),
|
|
2927
|
+
};
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
function getString(value: unknown): string | undefined {
|
|
2932
|
+
return typeof value === "string" ? value : undefined;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
class CodexProviderStreamError extends Error {
|
|
2936
|
+
readonly retryable: boolean;
|
|
2937
|
+
readonly code?: string;
|
|
2938
|
+
|
|
2939
|
+
constructor(message: string, retryable: boolean, code?: string) {
|
|
2940
|
+
super(message);
|
|
2941
|
+
this.name = "CodexProviderStreamError";
|
|
2942
|
+
this.retryable = retryable;
|
|
2943
|
+
this.code = code;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
function isRetryableCodexFailureEvent(rawEvent: Record<string, unknown>): boolean {
|
|
2948
|
+
const response = asRecord(rawEvent.response);
|
|
2949
|
+
const error = asRecord(rawEvent.error) ?? (response ? asRecord(response.error) : null);
|
|
2950
|
+
const code = getString(error?.code) ?? getString(error?.type) ?? getString(rawEvent.code);
|
|
2951
|
+
if (code && CODEX_RETRYABLE_EVENT_CODES.has(code.toLowerCase())) {
|
|
2952
|
+
return true;
|
|
2953
|
+
}
|
|
2954
|
+
const message = getString(error?.message) ?? getString(rawEvent.message) ?? getString(response?.message);
|
|
2955
|
+
return !!message && CODEX_RETRYABLE_EVENT_MESSAGE.test(message);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
function createCodexProviderStreamError(rawEvent: Record<string, unknown>): CodexProviderStreamError {
|
|
2959
|
+
const code = getString(rawEvent.code) ?? "";
|
|
2960
|
+
const message = getString(rawEvent.message) ?? "";
|
|
2961
|
+
const formattedMessage =
|
|
2962
|
+
typeof rawEvent.type === "string" && rawEvent.type === "error"
|
|
2963
|
+
? formatCodexErrorEvent(rawEvent, code, message)
|
|
2964
|
+
: (formatCodexFailure(rawEvent) ?? "Codex response failed");
|
|
2965
|
+
return new CodexProviderStreamError(formattedMessage, isRetryableCodexFailureEvent(rawEvent), code || undefined);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
function isRetryableCodexProviderError(error: unknown): boolean {
|
|
2969
|
+
return error instanceof CodexProviderStreamError && error.retryable;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
function truncate(text: string, limit: number): string {
|
|
2973
|
+
if (text.length <= limit) return text;
|
|
2974
|
+
return `${text.slice(0, limit)}…[truncated ${text.length - limit}]`;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
function formatCodexFailure(rawEvent: Record<string, unknown>): string | null {
|
|
2978
|
+
const response = asRecord(rawEvent.response);
|
|
2979
|
+
const error = asRecord(rawEvent.error) ?? (response ? asRecord(response.error) : null);
|
|
2980
|
+
const message = getString(error?.message) ?? getString(rawEvent.message) ?? getString(response?.message);
|
|
2981
|
+
const code = getString(error?.code) ?? getString(error?.type) ?? getString(rawEvent.code);
|
|
2982
|
+
const status = getString(response?.status) ?? getString(rawEvent.status);
|
|
2983
|
+
|
|
2984
|
+
const meta: string[] = [];
|
|
2985
|
+
if (code) meta.push(`code=${code}`);
|
|
2986
|
+
if (status) meta.push(`status=${status}`);
|
|
2987
|
+
|
|
2988
|
+
if (message) {
|
|
2989
|
+
const metaText = meta.length ? ` (${meta.join(", ")})` : "";
|
|
2990
|
+
return `Codex response failed: ${message}${metaText}`;
|
|
2991
|
+
}
|
|
2992
|
+
if (meta.length) {
|
|
2993
|
+
return `Codex response failed (${meta.join(", ")})`;
|
|
2994
|
+
}
|
|
2995
|
+
try {
|
|
2996
|
+
return `Codex response failed: ${truncate(JSON.stringify(rawEvent), 800)}`;
|
|
2997
|
+
} catch {
|
|
2998
|
+
return "Codex response failed";
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function formatCodexErrorEvent(rawEvent: Record<string, unknown>, code: string, message: string): string {
|
|
3003
|
+
const detail = formatCodexFailure(rawEvent);
|
|
3004
|
+
if (detail) {
|
|
3005
|
+
return detail.replace("response failed", "error event");
|
|
3006
|
+
}
|
|
3007
|
+
const meta: string[] = [];
|
|
3008
|
+
if (code) meta.push(`code=${code}`);
|
|
3009
|
+
if (message) meta.push(`message=${message}`);
|
|
3010
|
+
if (meta.length > 0) {
|
|
3011
|
+
return `Codex error event (${meta.join(", ")})`;
|
|
3012
|
+
}
|
|
3013
|
+
try {
|
|
3014
|
+
return `Codex error event: ${truncate(JSON.stringify(rawEvent), 800)}`;
|
|
3015
|
+
} catch {
|
|
3016
|
+
return "Codex error event";
|
|
3017
|
+
}
|
|
3018
|
+
}
|