Skip to content

feat: add OpenAI Responses WebSocket transport for /v1/responses#882

Closed
ding113 wants to merge 6 commits intodevfrom
feat/codex-ws
Closed

feat: add OpenAI Responses WebSocket transport for /v1/responses#882
ding113 wants to merge 6 commits intodevfrom
feat/codex-ws

Conversation

@ding113
Copy link
Owner

@ding113 ding113 commented Mar 8, 2026

Summary

This PR adds OpenAI /v1/responses WebSocket transport support, enabling Codex CLI and other clients to use native WebSocket streaming instead of SSE. The implementation follows the existing enableHttp2 pattern with a global system toggle (enableResponsesWebSocket).

Problem

  • Codex CLI and other OpenAI clients are transitioning to WebSocket transport for the /v1/responses endpoint for better real-time interaction
  • The existing proxy only supports HTTP/SSE transport for the Responses API
  • No support for the OpenAI WebSocket protocol framing and terminal state management

Solution

Architecture

  • Same-port architecture: Single HTTP server with server.on("upgrade") for WS -- no second port required
  • Delayed bridging: WS ingress buffers frames until response.create, then runs deferred guards and selects provider
  • Bounded memory: Head+tail ring-buffer collector (default 16+16) for event accumulation; 64 KiB SSE buffer cap on ingress bridge
  • Neutral fallback: WS handshake/proxy/URL failures fall back to HTTP without touching circuit breaker; mid-stream breaks surface as explicit failures with no silent recovery
  • Terminal-event settlement: Usage/cost extracted only from response.completed / response.failed / response.incomplete / error events

Security Hardening

  • WebSocket maxPayload: 1 MiB to prevent DoS via oversized frames
  • AbortSignal propagation from client to upstream WS adapter to prevent resource leaks on disconnect
  • 30s ping/pong keepalive to survive load balancer idle timeouts
  • websocketUrl sanitized before persisting to special settings

Changes

Core Changes

  • New custom server (server.js): Node.js server handling both HTTP and /v1/responses WebSocket upgrade with delayed-bridging ingress
  • New WS transport files:
    • responses-ws-adapter.ts: Outbound Responses WebSocket adapter with configurable timeouts
    • responses-ws-schema.ts: Frame schema validation
    • responses-ws-terminal-finalization.ts: Terminal event handling
    • responses-ws-transport.ts: Transport selector with HTTP fallback
  • Integration (forwarder.ts): Request-scoped outbound WS adapter integration with automatic HTTP fallback

Supporting Changes

  • Database: Migration 0079_chilly_mentallo.sql adds enable_responses_websocket to system config
  • Settings UI: Toggle in System Settings form with i18n support (5 languages)
  • Provider testing: Extended test service and UI to probe WebSocket capability with transport metadata
  • Observability: ResponsesWebSocketTransportSpecialSetting for debugging
  • Types: Updated special settings and message types for WebSocket transport

Tests

  • Unit tests: 4 new test files covering frame schema, terminal finalization, transport selector, toggle behavior
  • Integration tests: 8 new test files covering billing parity, server bootstrap, HTTP regression, delayed bridging, outbound adapter, provider testing, session continuity
  • UI tests: Provider test WS UI component tests

Breaking Changes

None. This is a fully backwards-compatible feature:

  • Toggle is off by default; existing HTTP /v1/responses behavior unchanged
  • When enabled, only Codex-style providers attempt WS first with automatic HTTP fallback
  • No changes to existing API contracts or response formats

Testing

Automated Tests

  • Unit tests added: tests/unit/responses-ws-*.test.ts (4 files)
  • Unit tests added: tests/unit/system-settings-responses-websocket-toggle.test.ts
  • Unit tests added: tests/unit/provider-test-ws-ui.test.tsx
  • Integration tests added: tests/integration/responses-ws/*.test.ts (8 files)
  • All existing tests pass

Manual Testing Checklist

  • bun run typecheck passes
  • bun run lint passes
  • bun run build succeeds
  • Toggle off: all existing HTTP /v1/responses behavior unchanged
  • Toggle on: Codex provider attempts WS first, falls back to HTTP on transport failure
  • Provider test UI shows WS transport metadata (handshake, events, fallback reason)
  • Settings UI renders new toggle with localized labels (5 languages)

Checklist

  • Code follows project conventions (Biome formatting, 2-space indent, 100 char width)
  • Self-review completed (security hardening from code review addressed)
  • i18n support added for all user-facing strings (5 languages)
  • Database migration generated via bun run db:generate
  • Tests added for new functionality (unit + integration)

Description enhanced from original version authored by ding113

Greptile Summary

This PR introduces WebSocket transport support for the /v1/responses endpoint, enabling Codex CLI clients to use native WS streaming. The implementation follows the existing enableHttp2 toggle pattern and is off-by-default, making it fully backwards-compatible. The architecture is well-thought-out: a single-port HTTP+WS server handles upgrade requests, buffers incoming WS frames until response.create arrives, and bridges them to the existing loopback SSE handler via forwarder.ts — which in turn can dispatch outbound via either WS (new responses-ws-adapter.ts) or HTTP.

Key issues found:

  • Logic bug in server.js line 282: The non-SSE loopback response fallback calls response.json().catch(() => response.text()), but after json() fails (JSON parse error), the response body is already fully consumed — text() in the catch handler will throw or return an empty string. Fix: read text() first and attempt JSON.parse manually.
  • Resource leak in server.js line 337: When a terminal SSE event causes the read loop to break, the ReadableStreamReader is never cancel()'d, leaving the loopback TCP connection open until the server naturally closes it. Fix: call reader.cancel() before break.
  • Style/Security in responses-ws-adapter.ts toSseChunk (line 61): event.type is used directly in the SSE event line without stripping newline characters. The Zod schema does not reject \r/\n in the type field, so a maliciously-crafted upstream frame could inject extra SSE fields. Fix: sanitize event.type before embedding.
  • Style in responses-ws-adapter.ts line 163–190: Both the ws library's built-in handshakeTimeout option and a manual setTimeout are set to the identical duration. Which fires first is non-deterministic, producing either "transport_setup_failed" or "handshake_timeout" as the fallbackReason. The functional HTTP-fallback decision is unaffected, but observability metadata is unreliable. Fix: rely on only one mechanism.

Confidence Score: 3/5

  • Safe to merge after fixing the body double-consumption bug and the SSE reader leak in server.js; the toggle is off-by-default so existing behavior is unaffected.
  • Two logic bugs in server.js (body consumed before text() fallback, reader not cancelled on terminal event) and one potential SSE injection vector in the adapter lower confidence. The core architecture is solid and the toggle is disabled by default, limiting blast radius, but the bugs should be fixed before enabling the feature in production.
  • server.js (lines 282–283 body consumption bug, lines 337–340 reader leak) and src/app/v1/_lib/proxy/responses-ws-adapter.ts (lines 59–62 SSE injection, lines 163–190 dual timeout).

Important Files Changed

Filename Overview
server.js New custom HTTP+WebSocket server bridging WS ingress to loopback SSE; has two logic bugs: body double-consumption in non-SSE fallback and SSE reader not cancelled after terminal-event break.
src/app/v1/_lib/proxy/responses-ws-adapter.ts Outbound WebSocket adapter with SSE conversion; dual handshake timeout creates non-deterministic fallback reason, and unvalidated event.type can produce SSE injection.
src/app/v1/_lib/proxy/responses-ws-transport.ts Transport selector cleanly evaluates provider capabilities and builds the WS URL with proper HTTP/WS protocol conversion; fallback classification logic is sound.
src/app/v1/_lib/proxy/responses-ws-schema.ts Zod schemas for WS frame validation are well-structured; terminal event set is in sync with server.js as noted in the comment.
src/app/v1/_lib/proxy/responses-ws-terminal-finalization.ts Head+tail ring-buffer collector and terminal event normalization are correctly implemented with no issues found.
src/app/v1/_lib/proxy/forwarder.ts WS transport integration follows the existing provider-chain pattern correctly; fallback to HTTP on allowHttpFallback is well-guarded.
src/lib/provider-testing/test-service.ts WS provider test path added for codex providers; unsafe body cast at line 287 was flagged in a previous review thread.
src/lib/config/system-settings-cache.ts enableResponsesWebSocket added to the cache with correct default (false) and properly exported helper; no issues found.
drizzle/0079_chilly_mentallo.sql Single ALTER TABLE adding enable_responses_websocket boolean column with FALSE default; migration is minimal and correct.

Sequence Diagram

sequenceDiagram
    participant Client as Codex Client (WS)
    participant Server as server.js (WS Ingress)
    participant Forwarder as forwarder.ts
    participant Adapter as responses-ws-adapter.ts
    participant Upstream as Upstream Provider (WS)

    Client->>Server: WS upgrade /v1/responses
    Server->>Client: 101 Switching Protocols

    Client->>Server: {"type":"response.create", "response":{...}}
    Note over Server: validateCreateFrame()
    Server->>Forwarder: POST /v1/responses (loopback SSE)
    Note over Forwarder: selectResponsesWsTransport()
    alt WS transport enabled & codex provider
        Forwarder->>Adapter: sendResponsesWsRequest()
        Adapter->>Upstream: WS connect + response.create frame
        Upstream-->>Adapter: stream of server events
        Adapter-->>Forwarder: SSE ReadableStream response
    else HTTP fallback
        Forwarder->>Upstream: HTTP POST /v1/responses
        Upstream-->>Forwarder: SSE response
    end
    Forwarder-->>Server: SSE stream (loopback)
    Note over Server: parseSseBlock() → sseEventToWsFrame()
    Server-->>Client: WS JSON frames (streaming)
    Note over Server: terminal event → break loop
    Server-->>Client: response.completed / response.failed
Loading

Last reviewed commit: 0259b73

Greptile also left 4 inline comments on this PR.

ding113 and others added 5 commits March 7, 2026 15:12
Introduce WebSocket-based transport for the Responses API, enabling
Codex CLI and other clients to use native WS streaming instead of SSE.

- Add custom Node.js server (server.js) with WS upgrade handling that
  bridges WebSocket frames to upstream SSE and back
- Implement WS transport layer: adapter, schema validation, transport
  selector, and terminal frame finalization
- Integrate WS transport into ProxyForwarder with automatic HTTP
  fallback on handshake/setup failures
- Add system setting toggle (enableResponsesWebSocket) with DB
  migration, validation, settings UI, and i18n (5 languages)
- Extend provider test service to support WS transport testing with
  diagnostic details (handshake time, event count, fallback reason)
- Add ResponsesWebSocketTransportSpecialSetting for observability
- Update Dockerfile for custom server, ws dependency tracing, and
  standalone build pipeline
- Add comprehensive unit and integration tests
Security:
- Add maxPayload (1 MiB) to WebSocket server to prevent DoS
- Propagate AbortSignal to upstream WS adapter so client disconnect
  aborts the upstream connection instead of leaking resources

Correctness:
- Unify terminal event type sets: add "error" to schema.ts to match
  server.js, preventing hang on upstream error events
- Replace unsafe string replace with TERMINAL_STATE_MAP lookup for
  deterministic terminal state derivation
- Add missing setEnableResponsesWebSocket state refresh after save
  in settings form
- Handle OpenAI protocol errors (previous_response_not_found,
  websocket_connection_limit_reached) as application-level terminal
  events without triggering HTTP fallback

Hardening:
- Add 30s ping/pong keepalive on ingress WS to survive LB idle
  timeouts
- Hoist TextEncoder to module scope to reduce GC pressure
- Sanitize websocketUrl before persisting to special settings
- Replace hardcoded beta header string with CONTEXT_1M_BETA_HEADER
  constant reference

Tests:
- Add tests for response.incomplete and error terminal states
- Add tests for disabled toggle and unsupported provider type paths
- Add tests for isResponsesWsTerminalEvent and response.cancel frame
- Add tests for OpenAI protocol error relay without HTTP fallback
- Fixed 'Type string | number | ((obj: any) => any' is not assignable to type Key | null | undefined' error by converting to string using String()
- Fixed formatter function call to convert arrays to strings when value is an array
- Fixed unused parameter warning by prefixing with underscore
- Fixed unused parameter in virtualized-logs-table.tsx

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

CI Run: https://github.com/ding113/claude-code-hub/actions/runs/22821806312
@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

本次提交在代理层引入“Responses WebSocket”传输支持:新增WS协议适配器、传输决策与终端事件处理、SSE↔WS桥接服务器、数据库 schema 与系统设置开关、前端 i18n/表单与测试覆盖。

Changes

Cohort / File(s) Summary
WebSocket 核心实现
server.js, src/app/v1/_lib/proxy/responses-ws-adapter.ts, src/app/v1/_lib/proxy/responses-ws-schema.ts, src/app/v1/_lib/proxy/responses-ws-terminal-finalization.ts, src/app/v1/_lib/proxy/responses-ws-transport.ts
新增完整的 Responses WS 协议实现:帧/事件 schema、发送适配器、超时与终端事件收集、传输决策与 URL 转换、错误分类与序列化。
代理集成与前端桥接
src/app/v1/_lib/proxy/forwarder.ts, src/app/v1/_lib/proxy/responses-ws-adapter.ts, src/app/v1/_lib/proxy/session.ts
在请求转发路径中尝试 WS 传输并在允许时回退至 HTTP,记录回退原因及会话链路条目。新增 header 清理/构建工具。
启动脚本与打包辅助
server.js, scripts/copy-version-to-standalone.cjs, Dockerfile, package.json, next.config.ts
引入自定义 server.js(含 WS 升级处理)、修改打包/启动脚本以生成 standalone-next-config,并将 ws 包加入依赖及类型。
数据库与数据层
drizzle/0079_chilly_mentallo.sql, drizzle/meta/_journal.json, src/drizzle/schema.ts, src/repository/system-config.ts, src/repository/_shared/transformers.ts
新增 enable_responses_websocket 列与相应 schema/mapping,更新 repository 的读写和默认值处理。
系统设置与配置缓存
src/types/system-config.ts, src/lib/config/system-settings-cache.ts, src/lib/config/index.ts, src/lib/validation/schemas.ts, src/actions/system-config.ts
添加 enableResponsesWebSocket 系统设置字段、校验规则及缓存访问器(isResponsesWebSocketEnabled),并将其暴露至前端设置表单。
前端表单、UI 与 i18n
src/app/[locale]/settings/config/_components/system-settings-form.tsx, src/app/[locale]/settings/config/page.tsx, messages/**/settings/**, src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx
在系统设置 UI 中新增开关与描述;新增多语言文案项;在测试结果卡中展示 transportKind、握手时延、事件计数与回退原因。
提供商测试与类型
src/lib/provider-testing/test-service.ts, src/lib/provider-testing/types.ts, src/actions/providers.ts, src/app/v1/_lib/codex/types/response.ts, src/lib/provider-testing/*
重构/扩展 provider 测试流以支持 HTTP 与 Responses WS 两条路径,扩展类型以携带 websocket 元数据和请求字段(prompt_cache_key、generate、reasoning.encrypted_content 等)。
特殊设置与审计
src/types/special-settings.ts, src/lib/utils/special-settings.ts, src/types/message.ts
新增 ResponsesWebSocketTransportSpecialSetting 类型并将其加入 SpecialSetting 联合,序列化 key 支持该类型以便审计/记录。
图表与 tooltip 稳定性修复
src/components/ui/chart.tsx, src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx
改用 String(dataKey) 强制为字符串以避免非字符串键导致的渲染或格式化异常。
测试(集成 + 单元)
tests/integration/responses-ws/*.test.ts, tests/unit/**/*.test.ts
新增大量集成/单元测试覆盖:WS 出站适配器、终端收集、传输选择、会话连续性、服务器自举、HTTP 回归与 UI 渲染等用例;并在测试 fixtures 中添加新的系统设置字段。
TypeScript / 构建配置
tsconfig.json, package.json
扩展 ts include 以包含 .next/dev 类型,升级 TypeScript 版本并引入 @types/ws

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • ding113
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 标题清晰描述了主要变更:添加OpenAI Responses WebSocket传输支持。标题与PR的核心功能完全相关,简洁明了。
Description check ✅ Passed PR描述详细阐述了WebSocket功能的实现细节、设计决策、安全加固、测试计划,与文件变更完全相符。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/codex-ws

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a significant architectural enhancement by integrating WebSocket support for the /v1/responses API. This change aims to improve the efficiency and responsiveness of interactions with AI providers by leveraging persistent, full-duplex connections. The implementation includes a unified server for both HTTP and WebSocket traffic, smart fallback to HTTP when WebSocket connections encounter issues, and detailed monitoring capabilities within the provider testing framework. The overall goal is to provide a more robust and performant communication layer while maintaining compatibility and existing system functionalities.

Highlights

  • WebSocket Transport for /v1/responses: Introduced a new WebSocket mode for the /v1/responses endpoint, allowing Codex and Responses providers to prefer WebSocket transport for real-time communication, controlled by a global system toggle.
  • Custom Node.js Server: Implemented a custom Node.js server (server.js) that handles both HTTP and WebSocket upgrades on the same port, ensuring efficient resource utilization and simplified deployment.
  • Intelligent Fallback Mechanism: Developed a request-scoped outbound Responses WebSocket adapter with configurable handshake and first-event timeouts, featuring automatic HTTP fallback for transport or setup failures without penalizing the circuit breaker.
  • Enhanced Provider Testing: Extended the provider testing backend and UI to probe WebSocket capabilities, displaying metrics such as transport kind, handshake latency, event count, and reasons for HTTP fallback.
  • Comprehensive Parity: Ensured that all existing functionalities, including billing, logging, session management, rate limiting, Langfuse integration, and special settings, maintain full parity through terminal-event-based settlement via the ProxyResponseHandler pipeline.
  • Security Hardening: Incorporated security measures such as a maxPayload limit for WebSockets, AbortSignal propagation to prevent resource leaks, 30-second ping/pong keepalives to counter load balancer timeouts, and sanitization of websocketUrl.
Changelog
  • Dockerfile
    • Added HOST and HOSTNAME environment variables.
    • Copied messages and .next/server directories for standalone builds.
  • drizzle/0079_chilly_mentallo.sql
    • Added enable_responses_websocket boolean column to the system_settings table with a default value of false.
  • drizzle/meta/_journal.json
    • Updated the Drizzle migration journal to include the new 0079_chilly_mentallo migration.
  • messages/en/settings/config.json
    • Added English localization for the 'Enable Responses WebSocket' toggle and its description.
  • messages/en/settings/providers/form/apiTest.json
    • Added English localization for new API test result fields: 'Transport', 'WS Events', 'WS Fallback', and 'WS Handshake'.
  • messages/ja/settings/config.json
    • Added Japanese localization for the 'Responses WebSocket を有効にする' toggle and its description.
  • messages/ja/settings/prices.json
    • Added Japanese localization for the 'マルチプロバイダー' badge.
  • messages/ja/settings/providers/form/apiTest.json
    • Added Japanese localization for new API test result fields: '転送方式', 'WS イベント数', 'WS フォールバック理由', and 'WS ハンドシェイク'.
  • messages/ru/settings/config.json
    • Added Russian localization for the 'Включить Responses WebSocket' toggle and its description.
  • messages/ru/settings/prices.json
    • Added Russian localization for the 'Несколько провайдеров' badge.
  • messages/ru/settings/providers/form/apiTest.json
    • Added Russian localization for new API test result fields: 'Транспорт', 'События WS', 'Причина отката WS', and 'WS-рукопожатие'.
  • messages/zh-CN/settings/config.json
    • Added Simplified Chinese localization for the '启用 Responses WebSocket' toggle and its description.
  • messages/zh-CN/settings/providers/form/apiTest.json
    • Added Simplified Chinese localization for new API test result fields: '传输方式', 'WS 事件数', 'WS 回退原因', and 'WS 握手'.
  • messages/zh-TW/settings/config.json
    • Added Traditional Chinese localization for the '啟用 Responses WebSocket' toggle and its description.
  • messages/zh-TW/settings/prices.json
    • Added Traditional Chinese localization for the '多供應商' badge.
  • messages/zh-TW/settings/providers/form/apiTest.json
    • Added Traditional Chinese localization for new API test result fields: '傳輸方式', 'WS 事件數', 'WS 回退原因', and 'WS 握手'.
  • next.config.ts
    • Included ws module in outputFileTracingIncludes for Next.js standalone builds.
  • package.json
    • Modified dev and start scripts to use node server.js.
    • Added ws as a dependency.
    • Updated typescript version to 5.9.3 and added @types/ws.
  • scripts/copy-version-to-standalone.cjs
    • Added functionality to extract and copy standalone-next-config.json and server.js for standalone Next.js applications.
  • server.js
    • Added a new custom Node.js server to manage both HTTP and WebSocket upgrades for /v1/responses.
    • Implemented WebSocket connection handling, SSE parsing, and error management within the server.
  • src/actions/providers.ts
    • Extended UnifiedTestResult type to include WebSocket-related metrics.
    • Modified testProviderUnified to pass WebSocket metrics.
  • src/actions/system-config.ts
    • Updated saveSystemSettings to support the new enableResponsesWebSocket setting.
  • src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx
    • Updated LatencyChart component to ensure dataKey is treated as a string.
  • src/app/[locale]/settings/config/_components/system-settings-form.tsx
    • Added a UI switch for the 'Enable Responses WebSocket' setting in the system configuration form.
  • src/app/[locale]/settings/config/page.tsx
    • Passed the enableResponsesWebSocket setting to the system settings form component.
  • src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx
    • Updated TestResultCard to display WebSocket transport details and fallback reasons in provider test results.
  • src/app/v1/_lib/codex/types/response.ts
    • Added prompt_cache_key and generate fields to ResponseRequest.
    • Extended ReasoningConfig with new effort options and encrypted_content.
  • src/app/v1/_lib/proxy/forwarder.ts
    • Integrated WebSocket transport logic into the ProxyForwarder for /v1/responses.
    • Included ResponsesWsTransportError, sendResponsesWsRequest, parseResponsesWsInitialFrame, and selectResponsesWsTransport.
  • src/app/v1/_lib/proxy/responses-ws-adapter.ts
    • Added a new file implementing the WebSocket adapter for sending and receiving frames, handling timeouts, and managing fallback logic.
  • src/app/v1/_lib/proxy/responses-ws-schema.ts
    • Added a new file defining Zod schemas for Responses WebSocket client frames, server events, and protocol errors.
  • src/app/v1/_lib/proxy/responses-ws-terminal-finalization.ts
    • Added a new file for collecting and normalizing Responses WebSocket terminal events.
  • src/app/v1/_lib/proxy/responses-ws-transport.ts
    • Added a new file containing logic for selecting WebSocket transport, classifying fallback reasons, and building special settings.
  • src/app/v1/_lib/proxy/session.ts
    • Added responses_websocket_fallback as a new reason type for provider chain items.
  • src/components/ui/chart.tsx
    • Improved handling of dataKey and value types in ChartTooltipContent for better chart rendering.
  • src/drizzle/schema.ts
    • Added enableResponsesWebSocket column to the Drizzle schema for system_settings.
  • src/lib/config/index.ts
    • Exported the isResponsesWebSocketEnabled function.
  • src/lib/config/system-settings-cache.ts
    • Included enableResponsesWebSocket in default system settings and caching mechanisms.
  • src/lib/provider-testing/test-service.ts
    • Refactored executeProviderTest to support WebSocket testing for Codex providers.
    • Introduced new helper functions for test setup, dispatcher creation, and WebSocket-specific result parsing.
  • src/lib/provider-testing/types.ts
    • Extended ProviderTestResult interface to include transportKind, websocketHandshakeMs, websocketEventCount, and websocketFallbackReason.
  • src/lib/utils/special-settings.ts
    • Added responses_websocket_transport to the buildSettingKey function.
    • Updated the CONTEXT_1M_BETA_HEADER usage.
  • src/lib/validation/schemas.ts
    • Added enableResponsesWebSocket to the UpdateSystemSettingsSchema.
  • src/repository/_shared/transformers.ts
    • Mapped enableResponsesWebSocket from database settings to the SystemSettings object.
  • src/repository/system-config.ts
    • Included enableResponsesWebSocket in createFallbackSettings, getSystemSettings, and updateSystemSettings functions.
  • src/types/message.ts
    • Added responses_websocket_fallback to the ProviderChainItem reason types.
  • src/types/special-settings.ts
    • Defined the ResponsesWebSocketTransportSpecialSetting type.
  • src/types/system-config.ts
    • Added enableResponsesWebSocket to the SystemSettings and UpdateSystemSettingsInput interfaces.
  • tests/integration/billing-model-source.test.ts
    • Added enableResponsesWebSocket to mock system settings in integration tests.
  • tests/integration/responses-ws/billing-observability-parity.test.ts
    • Added new integration tests for WebSocket billing and observability parity, covering service tier parsing and response body redaction.
  • tests/integration/responses-ws/custom-server-bootstrap.test.ts
    • Added new integration tests to verify the custom Node.js server correctly handles both HTTP and WebSocket upgrades on a single port.
  • tests/integration/responses-ws/http-regression-after-ws.test.ts
    • Added new integration tests to ensure the HTTP /v1/responses route remains functional after WebSocket support is added.
  • tests/integration/responses-ws/ingress-delayed-bridging.test.ts
    • Added new integration tests for WebSocket ingress delayed bridging, verifying frame buffering and provider resolution.
  • tests/integration/responses-ws/outbound-adapter.test.ts
    • Added new integration tests for the WebSocket outbound adapter, covering frame sending/receiving and timeout profile resolution.
  • tests/integration/responses-ws/provider-testing-backend.test.ts
    • Added new integration tests for the WebSocket provider testing backend, verifying capability reporting and fallback behavior.
  • tests/integration/responses-ws/session-continuity.test.ts
    • Added new integration tests for WebSocket session continuity, including re-binding sessions from prompt_cache_key and handling mid-stream disconnects and protocol errors.
  • tests/unit/actions/system-config-save.test.ts
    • Updated unit tests for saveSystemSettings to include the enableResponsesWebSocket field.
  • tests/unit/lib/config/system-settings-cache.test.ts
    • Updated unit tests for system settings cache to include enableResponsesWebSocket.
  • tests/unit/lib/rate-limit/lease-service.test.ts
    • Updated unit tests for LeaseService to include enableResponsesWebSocket in mock system settings.
  • tests/unit/lib/timezone/timezone-resolver.test.ts
    • Updated unit tests for timezone resolver to include enableResponsesWebSocket in mock system settings.
  • tests/unit/provider-test-ws-ui.test.tsx
    • Added new unit tests for the provider test WebSocket UI, verifying the rendering of WebSocket metadata and fallback reasons.
  • tests/unit/proxy/pricing-no-price.test.ts
    • Added enableResponsesWebSocket to mock system settings in unit tests.
  • tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
    • Added new unit tests for ProxyForwarder raw passthrough regression, ensuring original body bytes are preserved and hop-by-hop headers are filtered.
  • tests/unit/proxy/session.test.ts
    • Added enableResponsesWebSocket to mock system settings in unit tests.
  • tests/unit/repository/system-config-update-missing-columns.test.ts
    • Updated unit tests for system config update to include enableResponsesWebSocket.
  • tests/unit/responses-ws-frame-schema.test.ts
    • Added new unit tests for Responses WebSocket frame schema, verifying frame parsing, serialization, and terminal event identification.
  • tests/unit/responses-ws-terminal-finalization.test.ts
    • Added new unit tests for Responses WebSocket terminal finalization, verifying memory bounding and event normalization.
  • tests/unit/responses-ws-transport-selector.test.ts
    • Added new unit tests for Responses WebSocket transport selector, verifying fallback logic and unsupported scenarios.
  • tests/unit/system-settings-responses-websocket-toggle.test.ts
    • Added new unit tests for the system settings Responses WebSocket toggle, verifying its presence and correct handling.
  • tsconfig.json
    • Updated lib and paths configurations in tsconfig.json.
    • Added a new include path for .next/dev/dev/types/**/*.ts.
Activity
  • The pull request introduces a significant new feature, adding WebSocket support for /v1/responses to enhance real-time communication with AI providers.
  • A custom Node.js server has been implemented to efficiently manage both HTTP and WebSocket connections on a single port.
  • Extensive unit and integration tests have been added to ensure the stability and correctness of the new WebSocket functionality, covering server bootstrapping, adapter behavior, session continuity, and UI rendering.
  • Localization files across multiple languages (English, Japanese, Russian, Simplified Chinese, Traditional Chinese) have been updated to support the new WebSocket toggle and related UI elements, ensuring a consistent user experience.
  • Database schema changes were introduced to persist the new enableResponsesWebSocket setting, allowing administrators to control this feature.
  • Security hardening measures, including payload size limits, AbortSignal propagation, and keepalive mechanisms, have been incorporated based on thorough code review feedback.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions bot added enhancement New feature or request area:OpenAI area:provider area:i18n size/XL Extra Large PR (> 1000 lines) labels Mar 8, 2026
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is an impressive pull request that adds significant new functionality with WebSocket support for /v1/responses. The implementation is well-structured, introducing a custom Node.js server to handle both HTTP and WebSocket traffic on the same port, which is a clean solution. The new WebSocket adapter, SSE-to-WS bridge, and the fallback mechanism to HTTP are robustly implemented. The extensive test suite added for this new functionality is also commendable.

My review has one suggestion regarding the build script, which is a minor maintainability concern about a regular expression that could be fragile to future Next.js updates. Overall, this is a high-quality contribution.

Note: Security Review did not run due to the size of the PR.

Comment on lines +22 to +25
const match = content.match(/const nextConfig = (.+?)\n\nprocess\.env\.__NEXT_PRIVATE_STANDALONE_CONFIG/s);
if (!match) {
throw new Error("[copy-standalone] Failed to extract standalone nextConfig");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This regex is quite specific to the current output format of Next.js's standalone server file. It relies on const nextConfig = being followed by exactly two newlines and then process.env.__NEXT_PRIVATE_STANDALONE_CONFIG. This could break in future Next.js versions if they change their build output, causing the build to fail.

To improve maintainability, I'd suggest adding a comment to the code warning about this fragility. A more robust parsing method would be ideal, but might be complex to implement. For now, a warning comment would be very helpful for future updates.

Comment on lines +308 to +352
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}

buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_CHARS) {
throw new Error("Buffered SSE frame exceeded safety limit");
}

const { blocks, rest } = extractSseBlocks(buffer);
buffer = rest;

for (const block of blocks) {
const parsedEvent = parseSseBlock(block);
const frame = sseEventToWsFrame(parsedEvent);
if (!frame) {
continue;
}
writeJsonFrame(socket, frame);
if (RESPONSES_WS_TERMINAL_TYPES.has(frame.type)) {
terminalSeen = true;
}
}

if (terminalSeen) {
break;
}
}
} catch (error) {
if (!state.activeAbortController.signal.aborted) {
writeProtocolError(
socket,
"bridge_failed",
error instanceof Error ? error.message : "Responses WebSocket bridge failed"
);
}
} finally {
state.activeTurn = false;
state.activeAbortController = null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSE reader resource not cancelled on buffer overflow error.

When buffer.length > MAX_SSE_BUFFER_CHARS (line 319), a throw exits the while loop and the outer catch block at line 341 handles it, but reader.cancel() is never called. The underlying HTTP keep-alive connection to the loopback endpoint stays open, leaking the TCP slot until the server-side closes it.

Suggested change
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_CHARS) {
throw new Error("Buffered SSE frame exceeded safety limit");
}
const { blocks, rest } = extractSseBlocks(buffer);
buffer = rest;
for (const block of blocks) {
const parsedEvent = parseSseBlock(block);
const frame = sseEventToWsFrame(parsedEvent);
if (!frame) {
continue;
}
writeJsonFrame(socket, frame);
if (RESPONSES_WS_TERMINAL_TYPES.has(frame.type)) {
terminalSeen = true;
}
}
if (terminalSeen) {
break;
}
}
} catch (error) {
if (!state.activeAbortController.signal.aborted) {
writeProtocolError(
socket,
"bridge_failed",
error instanceof Error ? error.message : "Responses WebSocket bridge failed"
);
}
} finally {
state.activeTurn = false;
state.activeAbortController = null;
}
const reader = response.body?.getReader();
if (!reader) {
writeProtocolError(socket, "bridge_stream_missing", "Loopback bridge returned no SSE body");
return;
}
const decoder = new TextDecoder();
let buffer = "";
let terminalSeen = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value || value.byteLength === 0) {
continue;
}
buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_CHARS) {
throw new Error("Buffered SSE frame exceeded safety limit");
}
const { blocks, rest } = extractSseBlocks(buffer);
buffer = rest;
for (const block of blocks) {
const parsedEvent = parseSseBlock(block);
const frame = sseEventToWsFrame(parsedEvent);
if (!frame) {
continue;
}
writeJsonFrame(socket, frame);
if (RESPONSES_WS_TERMINAL_TYPES.has(frame.type)) {
terminalSeen = true;
}
}
if (terminalSeen) {
break;
}
}
} finally {
reader.cancel().catch(() => {});
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: server.js
Line: 308-352

Comment:
SSE reader resource not cancelled on buffer overflow error.

When `buffer.length > MAX_SSE_BUFFER_CHARS` (line 319), a `throw` exits the `while` loop and the outer `catch` block at line 341 handles it, but `reader.cancel()` is never called. The underlying HTTP keep-alive connection to the loopback endpoint stays open, leaking the TCP slot until the server-side closes it.

```suggestion
        const reader = response.body?.getReader();
        if (!reader) {
          writeProtocolError(socket, "bridge_stream_missing", "Loopback bridge returned no SSE body");
          return;
        }

        const decoder = new TextDecoder();
        let buffer = "";
        let terminalSeen = false;

        try {
          while (true) {
            const { done, value } = await reader.read();
            if (done) {
              break;
            }
            if (!value || value.byteLength === 0) {
              continue;
            }

            buffer += decoder.decode(value, { stream: true });
            if (buffer.length > MAX_SSE_BUFFER_CHARS) {
              throw new Error("Buffered SSE frame exceeded safety limit");
            }

            const { blocks, rest } = extractSseBlocks(buffer);
            buffer = rest;

            for (const block of blocks) {
              const parsedEvent = parseSseBlock(block);
              const frame = sseEventToWsFrame(parsedEvent);
              if (!frame) {
                continue;
              }
              writeJsonFrame(socket, frame);
              if (RESPONSES_WS_TERMINAL_TYPES.has(frame.type)) {
                terminalSeen = true;
              }
            }

            if (terminalSeen) {
              break;
            }
          }
        } finally {
          reader.cancel().catch(() => {});
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +285 to +289
frame: {
type: "response.create",
response: params.body as Record<string, unknown> & { model: string },
},
headers: params.headers,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe cast bypasses model field validation in WebSocket test payload.

params.body is cast directly to Record<string, unknown> & { model: string } without validating that model is actually a non-empty string. If the test body omits or has an invalid model field, the upstream provider will reject the frame with a protocol error surfaced as an opaque WS failure rather than a clear validation error.

The HTTP test path uses parseResponsesWsInitialFrame for validation. Use the same here:

Suggested change
frame: {
type: "response.create",
response: params.body as Record<string, unknown> & { model: string },
},
headers: params.headers,
let frame: ResponsesWsCreateFrame;
try {
frame = parseResponsesWsInitialFrame({
type: "response.create",
response: params.body,
});
} catch (err) {
throw new Error(`Invalid test payload for Responses WS: ${err instanceof Error ? err.message : String(err)}`);
}
const response = await sendResponsesWsRequest({
websocketUrl: decision.websocketUrl,
frame,
headers: params.headers,
isStreaming: true,
handshakeTimeoutMs: params.timeoutMs,
firstEventTimeoutMs: params.timeoutMs,
onOpen: (latencyMs) => {
handshakeLatencyMs = latencyMs;
},
onEvent: () => {
eventCount += 1;
},
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/provider-testing/test-service.ts
Line: 285-289

Comment:
Unsafe cast bypasses `model` field validation in WebSocket test payload.

`params.body` is cast directly to `Record<string, unknown> & { model: string }` without validating that `model` is actually a non-empty string. If the test body omits or has an invalid `model` field, the upstream provider will reject the frame with a protocol error surfaced as an opaque WS failure rather than a clear validation error.

The HTTP test path uses `parseResponsesWsInitialFrame` for validation. Use the same here:

```suggestion
    let frame: ResponsesWsCreateFrame;
    try {
      frame = parseResponsesWsInitialFrame({
        type: "response.create",
        response: params.body,
      });
    } catch (err) {
      throw new Error(`Invalid test payload for Responses WS: ${err instanceof Error ? err.message : String(err)}`);
    }

    const response = await sendResponsesWsRequest({
      websocketUrl: decision.websocketUrl,
      frame,
      headers: params.headers,
      isStreaming: true,
      handshakeTimeoutMs: params.timeoutMs,
      firstEventTimeoutMs: params.timeoutMs,
      onOpen: (latencyMs) => {
        handshakeLatencyMs = latencyMs;
      },
      onEvent: () => {
        eventCount += 1;
      },
    });
```

How can I resolve this? If you propose a fix, please make it concise.

Fixed CI formatting failures detected by Biome:
- src/components/ui/chart.tsx: indentation adjustments (143 lines changed)
- tsconfig.json: compact array/object formatting (22 lines changed)

CI Run: https://github.com/ding113/claude-code-hub/actions/runs/22823442958

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +282 to +283
const payload = await response.json().catch(async () => ({ body: await response.text() }));
if (payload && payload.error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Body already consumed when response.json() fails

response.json() internally calls response.text() which fully consumes the body stream. If JSON parsing fails, the body is now in a "disturbed" state. The catch handler then calls response.text() on an already-consumed body, which will throw TypeError: body used already (Node.js/undici) or return an empty string — meaning the { body: ... } fallback never actually captures any content.

The fix is to read text first and then attempt JSON parsing:

Suggested change
const payload = await response.json().catch(async () => ({ body: await response.text() }));
if (payload && payload.error) {
const text = await response.text().catch(() => "");
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
payload = { body: text };
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: server.js
Line: 282-283

Comment:
**Body already consumed when `response.json()` fails**

`response.json()` internally calls `response.text()` which fully consumes the body stream. If JSON parsing fails, the body is now in a "disturbed" state. The catch handler then calls `response.text()` on an already-consumed body, which will throw `TypeError: body used already` (Node.js/undici) or return an empty string — meaning the `{ body: ... }` fallback never actually captures any content.

The fix is to read `text` first and then attempt JSON parsing:

```suggestion
        const text = await response.text().catch(() => "");
        let payload: unknown;
        try {
          payload = JSON.parse(text);
        } catch {
          payload = { body: text };
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +337 to +340
if (terminalSeen) {
break;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loopback SSE stream not cancelled after terminal event break

When terminalSeen causes the while loop to exit via break, the reader obtained at line 298 is never reader.cancel()'d. The loopback HTTP keep-alive connection remains open and continues to receive/buffer SSE bytes from the Next.js server until the server-side naturally closes the stream (after [DONE]). Under load, this can hold open multiple TCP connections unnecessarily.

The fix is to cancel the reader before breaking:

Suggested change
if (terminalSeen) {
break;
}
}
if (terminalSeen) {
reader.cancel().catch(() => {});
break;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: server.js
Line: 337-340

Comment:
**Loopback SSE stream not cancelled after terminal event break**

When `terminalSeen` causes the `while` loop to exit via `break`, the `reader` obtained at line 298 is never `reader.cancel()`'d. The loopback HTTP keep-alive connection remains open and continues to receive/buffer SSE bytes from the Next.js server until the server-side naturally closes the stream (after `[DONE]`). Under load, this can hold open multiple TCP connections unnecessarily.

The fix is to cancel the reader before breaking:

```suggestion
          if (terminalSeen) {
            reader.cancel().catch(() => {});
            break;
          }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +59 to +62
function toSseChunk(event: ResponsesWsServerEvent): Uint8Array {
const payload = JSON.stringify(stripTypeField(event));
return sseEncoder.encode(`event: ${event.type}\ndata: ${payload}\n\n`);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unvalidated event.type used directly in SSE output — potential newline injection

event.type is validated by ResponsesWsServerEventSchema as z.string().trim().min(1), but this does not prevent embedded \n or \r\n characters. If an upstream provider sends a crafted type value like "response.completed\ndata: injected", the resulting SSE chunk would contain injected fields that a downstream SSE parser would interpret as real events.

Since the SSE Response is ultimately forwarded to the end client via forwarder.ts, this could confuse the client's SSE parser. Consider stripping or rejecting newlines:

Suggested change
function toSseChunk(event: ResponsesWsServerEvent): Uint8Array {
const payload = JSON.stringify(stripTypeField(event));
return sseEncoder.encode(`event: ${event.type}\ndata: ${payload}\n\n`);
}
function toSseChunk(event: ResponsesWsServerEvent): Uint8Array {
const safeType = event.type.replace(/[\r\n]/g, "");
const payload = JSON.stringify(stripTypeField(event));
return sseEncoder.encode(`event: ${safeType}\ndata: ${payload}\n\n`);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/responses-ws-adapter.ts
Line: 59-62

Comment:
**Unvalidated `event.type` used directly in SSE output — potential newline injection**

`event.type` is validated by `ResponsesWsServerEventSchema` as `z.string().trim().min(1)`, but this does not prevent embedded `\n` or `\r\n` characters. If an upstream provider sends a crafted `type` value like `"response.completed\ndata: injected"`, the resulting SSE chunk would contain injected fields that a downstream SSE parser would interpret as real events.

Since the SSE `Response` is ultimately forwarded to the end client via `forwarder.ts`, this could confuse the client's SSE parser. Consider stripping or rejecting newlines:

```suggestion
function toSseChunk(event: ResponsesWsServerEvent): Uint8Array {
  const safeType = event.type.replace(/[\r\n]/g, "");
  const payload = JSON.stringify(stripTypeField(event));
  return sseEncoder.encode(`event: ${safeType}\ndata: ${payload}\n\n`);
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +163 to +191
const socket = new WebSocket(params.websocketUrl, {
headers: params.headers,
handshakeTimeout: timeoutProfile.handshakeTimeoutMs,
});

const onAbort = params.abortSignal
? () => {
socket.close();
}
: null;
if (params.abortSignal && onAbort) {
if (params.abortSignal.aborted) {
socket.close();
} else {
params.abortSignal.addEventListener("abort", onAbort, { once: true });
}
}

handshakeTimeoutId = setTimeout(() => {
socket.terminate();
rejectTransport(
new ResponsesWsTransportError("Responses WebSocket handshake timed out", {
fallbackReason: "handshake_timeout",
allowHttpFallback: true,
upstreamRequestEstablished: false,
})
);
}, timeoutProfile.handshakeTimeoutMs);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dual handshake timeout produces non-deterministic fallbackReason

Both the ws library's built-in handshakeTimeout option (line 165) and the manual setTimeout at line 181 use the same timeoutProfile.handshakeTimeoutMs duration. This creates a race:

  • If the ws library fires first, it emits an error event ("Opening handshake has timed out") which reaches the once('error') handler and sets fallbackReason to "transport_setup_failed".
  • If the manual setTimeout fires first, it correctly sets fallbackReason to "handshake_timeout".

Since both timers have identical durations, which one fires first is non-deterministic (depends on the event loop). The intended "handshake_timeout" reason may never be observed in practice.

Consider removing the duplicate handshakeTimeout option from the ws constructor and relying solely on the manual setTimeout — or vice versa — to get a deterministic fallbackReason:

const socket = new WebSocket(params.websocketUrl, {
  headers: params.headers,
  // Remove handshakeTimeout here; the manual setTimeout below takes sole ownership
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/responses-ws-adapter.ts
Line: 163-191

Comment:
**Dual handshake timeout produces non-deterministic `fallbackReason`**

Both the `ws` library's built-in `handshakeTimeout` option (line 165) and the manual `setTimeout` at line 181 use the same `timeoutProfile.handshakeTimeoutMs` duration. This creates a race:

- If the `ws` library fires first, it emits an `error` event ("Opening handshake has timed out") which reaches the `once('error')` handler and sets `fallbackReason` to `"transport_setup_failed"`.
- If the manual `setTimeout` fires first, it correctly sets `fallbackReason` to `"handshake_timeout"`.

Since both timers have identical durations, which one fires first is non-deterministic (depends on the event loop). The intended `"handshake_timeout"` reason may never be observed in practice.

Consider removing the duplicate `handshakeTimeout` option from the `ws` constructor and relying solely on the manual `setTimeout` — or vice versa — to get a deterministic `fallbackReason`:

```typescript
const socket = new WebSocket(params.websocketUrl, {
  headers: params.headers,
  // Remove handshakeTimeout here; the manual setTimeout below takes sole ownership
});
```

How can I resolve this? If you propose a fix, please make it concise.

}

buffer += decoder.decode(value, { stream: true });
if (buffer.length > MAX_SSE_BUFFER_CHARS) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] [ERROR-SILENT] SSE reader not canceled on buffer overflow, leaking HTTP connections

Why this is a problem: When the SSE buffer exceeds the 64 KiB safety limit at line 318, the code throws an error. However, the catch block (line 341) and finally block (line 349) do not call reader.cancel() to release the HTTP response body reader. This leaves the loopback HTTP connection open, causing resource leaks that can exhaust system file descriptors under sustained oversized responses.

Suggested fix:

// In the catch block, ensure reader is cleaned up
} catch (error) {
  // Cancel the reader if it exists to prevent resource leak
  if (reader) {
    reader.cancel().catch(() => {});
  }
  if (!state.activeAbortController.signal.aborted) {
    writeProtocolError(
      socket,
      "bridge_failed",
      error instanceof Error ? error.message : "Responses WebSocket bridge failed"
    );
  }
} finally {
  state.activeTurn = false;
  state.activeAbortController = null;
}

Alternatively, wrap the entire while loop in a try/finally that guarantees reader cleanup:

try {
  while (true) {
    // ... existing loop code ...
  }
} finally {
  if (reader) {
    reader.cancel().catch(() => {});
  }
}

websocketUrl: decision.websocketUrl,
frame: {
type: "response.create",
response: params.body as Record<string, unknown> & { model: string },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [TYPE-ANY-USAGE] Unsafe body cast bypasses frame validation

Why this is a problem: The params.body is directly cast to Record<string, unknown> & { model: string } without running through parseResponsesWsInitialFrame(). In contrast, the forwarder.ts path validates the frame with parseResponsesWsInitialFrame() before sending. This inconsistency can produce cryptic transport errors instead of clear validation messages when the body structure is invalid.

Suggested fix:

import { parseResponsesWsInitialFrame } from "@/app/v1/_lib/proxy/responses-ws-schema";

// ... in executeResponsesWsProviderTest ...

const response = await sendResponsesWsRequest({
  websocketUrl: decision.websocketUrl,
  frame: parseResponsesWsInitialFrame({
    type: "response.create",
    response: params.body,
  }),
  headers: params.headers,
  isStreaming: true,
  handshakeTimeoutMs: params.timeoutMs,
  firstEventTimeoutMs: params.timeoutMs,
  onOpen: (latencyMs) => {
    handshakeLatencyMs = latencyMs;
  },
  onEvent: () => {
    eventCount += 1;
  },
});

This ensures consistent validation behavior between the proxy path and provider test path.

Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR adds OpenAI Responses WebSocket transport for /v1/responses, implementing a same-port architecture with delayed bridging ingress. The implementation is well-structured with comprehensive test coverage (4 unit tests, 8 integration tests). The feature is gated behind a default-off toggle, ensuring backward compatibility.

PR Size: XL

  • Lines changed: 17,031
  • Files changed: 70

Recommendation: Consider splitting future PRs of this size into smaller, focused changes for easier review. Suggested splits:

  • Core WebSocket transport layer (adapter, schema, transport selection)
  • Custom server with WS upgrade handling
  • UI/Settings integration and i18n
  • Provider testing extensions

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 0 0 0 0
Error Handling 1 0 0 0
Types 0 0 1 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

  1. [server.js:318] SSE reader not canceled on buffer overflow - When the 64 KiB buffer limit is exceeded, the reader is not cleaned up, causing HTTP connection leaks. See inline comment for fix.

High Priority Issues (Should Fix)

  1. [test-service.ts:287] Unsafe body cast bypasses frame validation - Inconsistent with the forwarder.ts path which uses parseResponsesWsInitialFrame(). See inline comment for fix.

Review Coverage

  • Logic and correctness - Clean
  • Security (OWASP Top 10) - Clean (maxPayload limit, URL sanitization, AbortSignal propagation)
  • Error handling - Issue found (SSE reader cleanup)
  • Type safety - Issue found (unsafe cast)
  • Documentation accuracy - Clean (comments are accurate, i18n complete)
  • Test coverage - Good (unit + integration tests for WS paths)
  • Code clarity - Good (well-structured, follows patterns)

Architecture Notes

The delayed-bridging design is sound:

  • WS ingress buffers frames until response.create, then runs deferred guards
  • Bounded memory via head/tail ring-buffer collector
  • Neutral fallback to HTTP without affecting circuit breaker
  • Terminal-event settlement for usage/cost extraction

The WebSocket feature is properly gated and safe to merge once the SSE reader leak is fixed.


Automated review by Claude AI

@ding113 ding113 closed this Mar 8, 2026
@github-project-automation github-project-automation bot moved this from Backlog to Done in Claude Code Hub Roadmap Mar 8, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (13)
tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts (3)

35-48: 类型断言可接受,但建议添加注释说明。

as unknown as Provider 是测试中创建部分 mock 对象的常见模式。建议添加简短注释说明这是测试用的最小化 Provider 对象,便于后续维护。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts` around
lines 35 - 48, The test helper createProvider() uses the double-cast "as unknown
as Provider" to coerce a minimal mock into Provider; add a concise inline
comment above or next to this return explaining that this is a minimal test-only
Provider shape and the double-cast is intentional for brevity (e.g.,
"Intentional minimal Provider for tests — using unknown->Provider cast to avoid
constructing full object"); keep the createProvider name and returned fields
unchanged so tests continue to use the same mock.

133-144: 通过类型断言测试私有方法存在脆弱性风险。

vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode")doForward 的访问方式绕过了 TypeScript 类型检查,直接测试内部实现细节。若 ProxyForwarder 重构这些方法,测试将静默失效。

建议:

  • 如果这些行为对外部调用者可见,考虑通过公共 API 测试
  • 如必须测试内部行为,在测试文件顶部添加注释说明依赖的内部 API

当前实现可接受,但需注意维护成本。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts` around
lines 133 - 144, The test bypasses TypeScript visibility by spying on internal
members (fetchWithoutAutoDecode) and casting ProxyForwarder to access doForward,
which makes the test brittle if internals are refactored; either rewrite the
test to exercise the same behavior via ProxyForwarder’s public API (calling
exported methods that trigger fetchWithoutAutoDecode/doForward) or, if you must
assert internal behavior, add a top-of-file comment documenting the dependency
on internal API and keep the spy but make it explicit by referencing the symbols
fetchWithoutAutoDecode, doForward, and ProxyForwarder so future maintainers know
this test relies on those internals.

50-108: Mock session 构造逻辑完整,但存在一定的维护风险。

直接使用 Object.create(ProxySession.prototype) + Object.assign 创建 mock session 是可行的,但若 ProxySession 接口变更,此处可能不会及时暴露兼容性问题。

可考虑:

  1. 使用 vi.mocked()Partial<ProxySession> 类型约束
  2. 抽取为可复用的 test factory/fixture
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts` around
lines 50 - 108, The current createRawPassthroughSession uses
Object.create(ProxySession.prototype) + Object.assign which is brittle if the
ProxySession shape changes; replace that pattern by building a strongly-typed
Partial<ProxySession> object (e.g. const session: Partial<ProxySession> = { ...
}) with the same properties and vi.fn stubs, then return session as ProxySession
to get compile-time validation, and/or extract createRawPassthroughSession into
a reusable test factory/fixture so other tests can reuse the typed factory; keep
the same unique symbols (createRawPassthroughSession, getCurrentModel,
addSpecialSetting, isHeaderModified, resolveEndpointPolicy) when moving code so
tests still reference the same behavior.
tests/unit/lib/rate-limit/lease-service.test.ts (1)

130-130: 把系统设置 mock 收敛到一个工厂。

enableResponsesWebSocket 只是这次新增的一个字段,但这里已经要在很多个 fixture 里同步同一份默认值。后面再扩展 SystemSettings 时,很容易漏改某个用例。建议抽一个 buildSystemSettingsMock(overrides),把默认值集中在一处。

Also applies to: 208-208, 273-273, 332-332, 402-402, 462-462, 522-522, 583-583, 646-646, 705-705, 766-766

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/lib/rate-limit/lease-service.test.ts` at line 130, Several tests
duplicate a SystemSettings literal (now including enableResponsesWebSocket)
causing brittle fixtures; create a factory function
buildSystemSettingsMock(overrides) that returns the full default SystemSettings
object (with enableResponsesWebSocket: false and all current defaults) and
accepts partial overrides, then replace the repeated inline SystemSettings mocks
in lease-service.test.ts (and other tests) with calls to
buildSystemSettingsMock({...}) so defaults are centralized and future fields
won't require updating every fixture.
src/app/v1/_lib/proxy/responses-ws-transport.ts (1)

5-5: 这里改用 @/ 路径别名。

当前文件已经在 src/ 下,继续使用相对导入会和仓库约定不一致。

建议修改
-import { sanitizeUrl } from "./errors";
+import { sanitizeUrl } from "@/app/v1/_lib/proxy/errors";

As per coding guidelines, "Use path alias @/ to reference files in ./src/ directory".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/v1/_lib/proxy/responses-ws-transport.ts` at line 5, Replace the
relative import of sanitizeUrl in responses-ws-transport.ts with the project
path-alias form: instead of importing from "./errors" update the import to use
the "@/..." alias that points into src (i.e. reference the same module via
"@/app/v1/_lib/proxy/errors"); keep the same imported symbol sanitizeUrl and
ensure the updated import matches the repo's tsconfig/jsconfig path alias
configuration.
tests/integration/responses-ws/http-regression-after-ws.test.ts (1)

31-73: 这个回归用例最好先走一轮真实的 WS 生命周期再验 HTTP。

当前只验证“挂上 upgrade 支持后 HTTP 路由还在”,还没覆盖“经历过一次 WebSocket 连接/关闭后 HTTP 仍然正常”。如果回归出在连接状态清理或全局单例污染,这个用例会漏掉。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/http-regression-after-ws.test.ts` around lines
31 - 73, 在现有测试(it "keeps the HTTP /v1/responses route alive after WS
support")中,在对 /v1/responses 发起 HTTP POST 之前模拟一次真实的 WS 生命周期:使用同样的 port(通过
getFreePort())和 spawned child 进程,建立一个 WebSocket 连接到服务器的实时端点(例如
ws://127.0.0.1:${port}/v1/realtime 或项目实际的 WS 路径),等待连接成功后立即关闭(或发送正常的
close/cleanup),确认连接已关闭,然后再执行现有的 fetch POST 对 /v1/responses 并断言 status;
修改点涉及该测试体内的 child 进程 orchestration、在 fetch 之前插入 WS 客户端连接/关闭逻辑以覆盖连接建立与清理场景。
tests/integration/responses-ws/billing-observability-parity.test.ts (1)

21-39: 脱敏测试可考虑增强边界场景覆盖。

当前测试验证了 reasoning.summaryfunction_call.arguments 的脱敏。建议后续考虑添加以下场景的测试覆盖(可在后续 PR 中完成):

  • 嵌套更深的敏感数据结构
  • 空值或 undefined 字段的处理
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/billing-observability-parity.test.ts` around
lines 21 - 39, Add additional test cases to the existing "redacts reasoning and
tool payloads in trace/log path" suite that exercise deeper and edge-case
inputs: call redactResponseBody with (1) nested-sensitive structures (e.g.,
reasoning.summary containing nested objects/arrays with secret strings and
function_call.arguments as a nested JSON object string) and assert
JSON.stringify(redacted) does not contain secrets, and (2) inputs with null,
undefined, or missing summary/arguments fields to verify they are handled
without errors; reuse the same test semantics and assertions around
redactResponseBody to ensure these boundaries are covered.
tests/integration/responses-ws/custom-server-bootstrap.test.ts (2)

23-23: child 变量添加类型注解。

当前声明缺少类型信息,建议添加显式类型以提高代码可读性和 TypeScript 类型安全性。

建议的修复
-  let child = null;
+  let child: ReturnType<typeof spawn> | null = null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/custom-server-bootstrap.test.ts` at line 23,
The variable "child" is declared without a TypeScript type; update its
declaration in the test to include an explicit type (e.g., ChildProcess | null
or the appropriate test fixture type) so it is not implicitly any—modify the
"let child = null;" declaration to "let child: ChildProcess | null = null;" (or
another accurate type used in this test) and import any needed types such as
ChildProcess from "child_process" if required.

60-68: stderr 处理可能过于严格。

当前实现将任何 stderr 输出都视为错误并拒绝 Promise。这可能导致误报,因为服务器可能会在 stderr 上输出警告信息(如 deprecation warnings)而非真正的错误。

建议考虑更宽松的处理策略,例如仅在检测到特定错误模式时才拒绝,或者允许某些警告类型通过。

建议的修复
       const handleError = (chunk) => {
         const text = chunk.toString();
-        if (text.trim()) {
+        // Only treat fatal errors as failures, allow warnings to pass
+        if (text.includes("Error:") || text.includes("FATAL") || text.includes("Cannot find module")) {
           clearTimeout(timeout);
           child.stdout?.off("data", handleReady);
           child.stderr?.off("data", handleError);
           reject(new Error(text));
         }
       };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/custom-server-bootstrap.test.ts` around lines
60 - 68, The current handleError treats any stderr chunk as a fatal error;
update handleError (used with child.stderr) to inspect the text and only reject
when it matches real error patterns (e.g., /error|exception|failed|unhandled/i
or a configurable list), otherwise log or ignore warnings and do not clear the
startup timeout or remove the handleReady/handleError listeners for non-fatal
stderr; ensure you still reject and perform clearTimeout/offs when a true error
pattern is detected so the Promise behavior remains correct.
src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx (1)

260-267: websocketFallbackReason 值可能需要 i18n 处理。

当前实现直接显示 result.websocketFallbackReason 的原始值(如 proxy_incompatibledisabledunsupported_provider_type),而其他字段(如 transportKind)都使用了 i18n 翻译。为保持用户体验一致性,建议对 fallback reason 值也进行翻译。

建议的修复
         {result.websocketFallbackReason && (
           <div className="flex items-center gap-1">
             <span className="text-muted-foreground">
               {t("resultCard.labels.websocketFallbackReason")}:
             </span>
-            <span>{result.websocketFallbackReason}</span>
+            <span>{t(`resultCard.fallbackReason.${result.websocketFallbackReason}`)}</span>
           </div>
         )}

同时需要在对应的 i18n JSON 文件中添加 fallback reason 的翻译键:

{
  "resultCard": {
    "fallbackReason": {
      "proxy_incompatible": "代理不兼容",
      "disabled": "已禁用",
      "unsupported_provider_type": "不支持的供应商类型",
      "handshake_timeout": "握手超时"
    }
  }
}

Also applies to: 445-452

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/providers/_components/forms/test-result-card.tsx
around lines 260 - 267, The UI is rendering raw result.websocketFallbackReason
values instead of translated text; update the rendering in the test-result-card
component to pass the fallback reason through the i18n translator (e.g., use t
with a key like "resultCard.fallbackReason.{result.websocketFallbackReason}" or
a safe mapping function) instead of showing result.websocketFallbackReason
directly, and add the corresponding keys and translations (proxy_incompatible,
disabled, unsupported_provider_type, handshake_timeout, etc.) to the
resultCard.fallbackReason entries in the locale JSON files so all fallback
reasons are localized.
src/actions/providers.ts (1)

4249-4252: websocketFallbackReason 收敛成稳定代码,不要暴露自由字符串。

如果前端要展示这个原因,string 只会迫使 UI 直接显示后端文案,或者做脆弱的字符串匹配;两种方式都会让 i18n 和兼容性变差。这里更适合返回有限的 reason code(例如 handshake_timeoutupgrade_rejected),再由前端映射到多语言文案。

As per coding guidelines "All user-facing strings must use i18n (5 languages supported: zh-CN, zh-TW, en, ja, ru). Never hardcode display text"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/providers.ts` around lines 4249 - 4252, The
websocketFallbackReason field currently typed as string should be changed to a
closed set of reason codes (e.g., a string literal union or enum) such as
"handshake_timeout" | "upgrade_rejected" | "client_cancelled" |
"unsupported_protocol" to avoid exposing freeform backend strings; update the
type declaration for websocketFallbackReason in the transport/options interface
and all places that set it (search for websocketFallbackReason assignments) to
emit one of these codes, and ensure any code paths that previously wrote
human-readable messages now map internal error details to one of the stable
codes so the frontend can perform i18n mapping.
server.js (2)

8-13: 终端事件类型集合需要与 TypeScript 模块保持同步。

RESPONSES_WS_TERMINAL_TYPES 在此处定义,同时在 responses-ws-schema.ts 中也有 RESPONSES_WS_TERMINAL_EVENT_TYPES。注释中提到需要保持同步(见 responses-ws-schema.ts 第 71 行),但目前没有自动化机制保证这一点。

建议在代码注释中添加对应的同步说明,或者考虑在测试中验证两边的一致性。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.js` around lines 8 - 13, Add a sync note and an automated check:
update the comment above RESPONSES_WS_TERMINAL_TYPES to explicitly state it must
match RESPONSES_WS_TERMINAL_EVENT_TYPES (the exported constant in
responses-ws-schema.ts) and add a unit test that imports both symbols
(RESPONSES_WS_TERMINAL_TYPES from server.js and
RESPONSES_WS_TERMINAL_EVENT_TYPES from the compiled/JS export of
responses-ws-schema.ts) and asserts their sets/arrays are identical; if your
test framework requires, convert both to arrays or sets before comparison and
fail the test with a clear message when they diverge.

308-340: SSE 流处理循环逻辑正确,但缺少对 reader 的显式清理。

当循环正常结束(doneterminalSeen)时,reader 没有被显式释放。虽然在大多数情况下 GC 会处理,但在某些边缘情况下(如流未完全消费)可能导致资源挂起。

建议添加 reader 清理
         while (true) {
           const { done, value } = await reader.read();
           if (done) {
             break;
           }
           // ... existing logic ...
           
           if (terminalSeen) {
             break;
           }
         }
+        
+        reader.releaseLock();
       } catch (error) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server.js` around lines 308 - 340, Wrap the SSE read loop that uses reader in
a try/finally so the reader is explicitly cleaned up when the loop exits; after
the existing while loop (or in the finally block) call await reader.cancel()
(guarded with if (reader) and try/catch to swallow non-fatal errors) to ensure
the stream is closed and resources freed — update the block around the reader
usage in server.js (the code using reader, extractSseBlocks, parseSseBlock,
sseEventToWsFrame, writeJsonFrame, and RESPONSES_WS_TERMINAL_TYPES) to perform
this explicit cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/copy-version-to-standalone.cjs`:
- Around line 16-28: The function extractStandaloneNextConfig currently relies
on a fragile regex that targets Next.js's private symbol
__NEXT_PRIVATE_STANDALONE_CONFIG and calls JSON.parse(match[1]) without error
handling; wrap the JSON.parse in a try-catch inside extractStandaloneNextConfig
to catch syntax errors and rethrow a clearer error that includes the extracted
snippet and a hint about Next.js internal format changes, and consider making
the regex extraction more defensive (e.g., trim the captured group and validate
it looks like JSON before parsing) so failures surface with actionable messages
instead of crashing.

In `@src/app/`[locale]/settings/config/_components/system-settings-form.tsx:
- Around line 393-412: The Switch with id "enable-responses-websocket" lacks an
accessible label; update the JSX around the Switch (the block rendering
enableResponsesWebSocket, t("enableResponsesWebSocket"),
t("enableResponsesWebSocketDesc")) to programmatically associate the visible
text with the control — e.g., add a <label htmlFor="enable-responses-websocket">
(or add aria-labelledby referencing the title/description IDs and
aria-describedby for the description) so the Switch (id
"enable-responses-websocket") is announced properly; keep the existing
checked={enableResponsesWebSocket},
onCheckedChange={setEnableResponsesWebSocket} and disabled={isPending} props
unchanged.

In `@src/app/v1/_lib/proxy/responses-ws-transport.ts`:
- Around line 55-74: The current toResponsesWebSocketUrl implementation uses
pathname.includes("/responses") which incorrectly matches substrings like
"/responses-debug"; change to parse URL.pathname, split by "/", ignore empty
segments, and require the last segment === "responses". Also separate protocol
validation from path validation: in toResponsesWebSocketUrl first
check/normalize supported protocols (http->ws, https->wss, allow ws/wss,
otherwise return null) and then validate the final path segment; apply the same
exact checks in the code that sets websocketFallbackReason so it distinguishes
unsupported_protocol vs unsupported_path (e.g., use symbols to indicate
'unsupported_protocol' when protocol unsupported and 'unsupported_endpoint' or
'unsupported_path' when last path segment !== "responses").

In `@src/components/ui/chart.tsx`:
- Around line 173-181: The current map uses dataKeyStr as the React list key
which can be non-unique; update the key generation inside the map in
src/components/ui/chart.tsx (the .map((item, index) => { ... }) block) to
guarantee uniqueness by combining dataKeyStr with the index or the computed key
variable (e.g., use `${dataKeyStr}-${index}` or `${key}-${index}`), so replace
the existing key={dataKeyStr} with a deterministic unique key that uses index
and/or the computed key/nameKey to avoid duplicate keys.

In `@tests/integration/responses-ws/ingress-delayed-bridging.test.ts`:
- Line 2: The test currently uses getFreePort() to reserve a port then closes
the temp server and calls realServer.listen(port), creating a TOCTOU race;
instead, have the real http.Server call listen(0) directly and then read the
assigned port from server.address(). Specifically, remove the temp
port-reservation flow (getFreePort and its temporary net.createServer usage) and
change the real server listen call to server.listen(0) (where the code
referenced as the real server and the Promise around listen is created), then
after listening resolve the Promise using (server.address() as
AddressInfo).port; also ensure the Promise registers an 'error' handler on the
real server to reject on listen errors to avoid hanging.

In `@tests/integration/responses-ws/outbound-adapter.test.ts`:
- Around line 121-136: The test currently only asserts the flex-tier
firstEventTimeoutMs is greater than 5000ms; update it to assert the resolved
timeout matches the minimum flex floor by checking that
timeoutProfile.firstEventTimeoutMs is at least FLEX_FIRST_EVENT_TIMEOUT_MS (or
equals 60000) to verify the Math.max logic in resolveResponsesWsTimeoutProfile;
reference the resolveResponsesWsTimeoutProfile function and the
FLEX_FIRST_EVENT_TIMEOUT_MS constant when adding the explicit assertion.

In `@tests/integration/responses-ws/session-continuity.test.ts`:
- Around line 240-276: The test creates a WebSocketServer named wss that may not
be closed if the test fails; add centralized cleanup (e.g., an afterEach)
similar to the other describe block to always close/await any created servers.
Concretely, track created servers (or the wss variable) in the surrounding
describe scope and in afterEach call wss.close() (await the close callback or
wrap in a Promise) to ensure the socket is closed and the port freed even when
the test throws; update the session-continuity.test.ts tests that create
WebSocketServer instances to use that shared cleanup.

---

Nitpick comments:
In `@server.js`:
- Around line 8-13: Add a sync note and an automated check: update the comment
above RESPONSES_WS_TERMINAL_TYPES to explicitly state it must match
RESPONSES_WS_TERMINAL_EVENT_TYPES (the exported constant in
responses-ws-schema.ts) and add a unit test that imports both symbols
(RESPONSES_WS_TERMINAL_TYPES from server.js and
RESPONSES_WS_TERMINAL_EVENT_TYPES from the compiled/JS export of
responses-ws-schema.ts) and asserts their sets/arrays are identical; if your
test framework requires, convert both to arrays or sets before comparison and
fail the test with a clear message when they diverge.
- Around line 308-340: Wrap the SSE read loop that uses reader in a try/finally
so the reader is explicitly cleaned up when the loop exits; after the existing
while loop (or in the finally block) call await reader.cancel() (guarded with if
(reader) and try/catch to swallow non-fatal errors) to ensure the stream is
closed and resources freed — update the block around the reader usage in
server.js (the code using reader, extractSseBlocks, parseSseBlock,
sseEventToWsFrame, writeJsonFrame, and RESPONSES_WS_TERMINAL_TYPES) to perform
this explicit cleanup.

In `@src/actions/providers.ts`:
- Around line 4249-4252: The websocketFallbackReason field currently typed as
string should be changed to a closed set of reason codes (e.g., a string literal
union or enum) such as "handshake_timeout" | "upgrade_rejected" |
"client_cancelled" | "unsupported_protocol" to avoid exposing freeform backend
strings; update the type declaration for websocketFallbackReason in the
transport/options interface and all places that set it (search for
websocketFallbackReason assignments) to emit one of these codes, and ensure any
code paths that previously wrote human-readable messages now map internal error
details to one of the stable codes so the frontend can perform i18n mapping.

In `@src/app/`[locale]/settings/providers/_components/forms/test-result-card.tsx:
- Around line 260-267: The UI is rendering raw result.websocketFallbackReason
values instead of translated text; update the rendering in the test-result-card
component to pass the fallback reason through the i18n translator (e.g., use t
with a key like "resultCard.fallbackReason.{result.websocketFallbackReason}" or
a safe mapping function) instead of showing result.websocketFallbackReason
directly, and add the corresponding keys and translations (proxy_incompatible,
disabled, unsupported_provider_type, handshake_timeout, etc.) to the
resultCard.fallbackReason entries in the locale JSON files so all fallback
reasons are localized.

In `@src/app/v1/_lib/proxy/responses-ws-transport.ts`:
- Line 5: Replace the relative import of sanitizeUrl in
responses-ws-transport.ts with the project path-alias form: instead of importing
from "./errors" update the import to use the "@/..." alias that points into src
(i.e. reference the same module via "@/app/v1/_lib/proxy/errors"); keep the same
imported symbol sanitizeUrl and ensure the updated import matches the repo's
tsconfig/jsconfig path alias configuration.

In `@tests/integration/responses-ws/billing-observability-parity.test.ts`:
- Around line 21-39: Add additional test cases to the existing "redacts
reasoning and tool payloads in trace/log path" suite that exercise deeper and
edge-case inputs: call redactResponseBody with (1) nested-sensitive structures
(e.g., reasoning.summary containing nested objects/arrays with secret strings
and function_call.arguments as a nested JSON object string) and assert
JSON.stringify(redacted) does not contain secrets, and (2) inputs with null,
undefined, or missing summary/arguments fields to verify they are handled
without errors; reuse the same test semantics and assertions around
redactResponseBody to ensure these boundaries are covered.

In `@tests/integration/responses-ws/custom-server-bootstrap.test.ts`:
- Line 23: The variable "child" is declared without a TypeScript type; update
its declaration in the test to include an explicit type (e.g., ChildProcess |
null or the appropriate test fixture type) so it is not implicitly any—modify
the "let child = null;" declaration to "let child: ChildProcess | null = null;"
(or another accurate type used in this test) and import any needed types such as
ChildProcess from "child_process" if required.
- Around line 60-68: The current handleError treats any stderr chunk as a fatal
error; update handleError (used with child.stderr) to inspect the text and only
reject when it matches real error patterns (e.g.,
/error|exception|failed|unhandled/i or a configurable list), otherwise log or
ignore warnings and do not clear the startup timeout or remove the
handleReady/handleError listeners for non-fatal stderr; ensure you still reject
and perform clearTimeout/offs when a true error pattern is detected so the
Promise behavior remains correct.

In `@tests/integration/responses-ws/http-regression-after-ws.test.ts`:
- Around line 31-73: 在现有测试(it "keeps the HTTP /v1/responses route alive after WS
support")中,在对 /v1/responses 发起 HTTP POST 之前模拟一次真实的 WS 生命周期:使用同样的 port(通过
getFreePort())和 spawned child 进程,建立一个 WebSocket 连接到服务器的实时端点(例如
ws://127.0.0.1:${port}/v1/realtime 或项目实际的 WS 路径),等待连接成功后立即关闭(或发送正常的
close/cleanup),确认连接已关闭,然后再执行现有的 fetch POST 对 /v1/responses 并断言 status;
修改点涉及该测试体内的 child 进程 orchestration、在 fetch 之前插入 WS 客户端连接/关闭逻辑以覆盖连接建立与清理场景。

In `@tests/unit/lib/rate-limit/lease-service.test.ts`:
- Line 130: Several tests duplicate a SystemSettings literal (now including
enableResponsesWebSocket) causing brittle fixtures; create a factory function
buildSystemSettingsMock(overrides) that returns the full default SystemSettings
object (with enableResponsesWebSocket: false and all current defaults) and
accepts partial overrides, then replace the repeated inline SystemSettings mocks
in lease-service.test.ts (and other tests) with calls to
buildSystemSettingsMock({...}) so defaults are centralized and future fields
won't require updating every fixture.

In `@tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts`:
- Around line 35-48: The test helper createProvider() uses the double-cast "as
unknown as Provider" to coerce a minimal mock into Provider; add a concise
inline comment above or next to this return explaining that this is a minimal
test-only Provider shape and the double-cast is intentional for brevity (e.g.,
"Intentional minimal Provider for tests — using unknown->Provider cast to avoid
constructing full object"); keep the createProvider name and returned fields
unchanged so tests continue to use the same mock.
- Around line 133-144: The test bypasses TypeScript visibility by spying on
internal members (fetchWithoutAutoDecode) and casting ProxyForwarder to access
doForward, which makes the test brittle if internals are refactored; either
rewrite the test to exercise the same behavior via ProxyForwarder’s public API
(calling exported methods that trigger fetchWithoutAutoDecode/doForward) or, if
you must assert internal behavior, add a top-of-file comment documenting the
dependency on internal API and keep the spy but make it explicit by referencing
the symbols fetchWithoutAutoDecode, doForward, and ProxyForwarder so future
maintainers know this test relies on those internals.
- Around line 50-108: The current createRawPassthroughSession uses
Object.create(ProxySession.prototype) + Object.assign which is brittle if the
ProxySession shape changes; replace that pattern by building a strongly-typed
Partial<ProxySession> object (e.g. const session: Partial<ProxySession> = { ...
}) with the same properties and vi.fn stubs, then return session as ProxySession
to get compile-time validation, and/or extract createRawPassthroughSession into
a reusable test factory/fixture so other tests can reuse the typed factory; keep
the same unique symbols (createRawPassthroughSession, getCurrentModel,
addSpecialSetting, isHeaderModified, resolveEndpointPolicy) when moving code so
tests still reference the same behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e9aaab2-eb1a-4508-b387-d66ef2dd6afd

📥 Commits

Reviewing files that changed from the base of the PR and between 2b60af9 and c6f518d.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (69)
  • Dockerfile
  • drizzle/0079_chilly_mentallo.sql
  • drizzle/meta/0079_snapshot.json
  • drizzle/meta/_journal.json
  • messages/en/settings/config.json
  • messages/en/settings/providers/form/apiTest.json
  • messages/ja/settings/config.json
  • messages/ja/settings/prices.json
  • messages/ja/settings/providers/form/apiTest.json
  • messages/ru/settings/config.json
  • messages/ru/settings/prices.json
  • messages/ru/settings/providers/form/apiTest.json
  • messages/zh-CN/settings/config.json
  • messages/zh-CN/settings/providers/form/apiTest.json
  • messages/zh-TW/settings/config.json
  • messages/zh-TW/settings/prices.json
  • messages/zh-TW/settings/providers/form/apiTest.json
  • next.config.ts
  • package.json
  • scripts/copy-version-to-standalone.cjs
  • server.js
  • src/actions/providers.ts
  • src/actions/system-config.ts
  • src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx
  • src/app/[locale]/settings/config/_components/system-settings-form.tsx
  • src/app/[locale]/settings/config/page.tsx
  • src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx
  • src/app/v1/_lib/codex/types/response.ts
  • src/app/v1/_lib/proxy/forwarder.ts
  • src/app/v1/_lib/proxy/responses-ws-adapter.ts
  • src/app/v1/_lib/proxy/responses-ws-schema.ts
  • src/app/v1/_lib/proxy/responses-ws-terminal-finalization.ts
  • src/app/v1/_lib/proxy/responses-ws-transport.ts
  • src/app/v1/_lib/proxy/session.ts
  • src/components/ui/chart.tsx
  • src/drizzle/schema.ts
  • src/lib/config/index.ts
  • src/lib/config/system-settings-cache.ts
  • src/lib/provider-testing/test-service.ts
  • src/lib/provider-testing/types.ts
  • src/lib/utils/special-settings.ts
  • src/lib/validation/schemas.ts
  • src/repository/_shared/transformers.ts
  • src/repository/system-config.ts
  • src/types/message.ts
  • src/types/special-settings.ts
  • src/types/system-config.ts
  • tests/integration/billing-model-source.test.ts
  • tests/integration/responses-ws/billing-observability-parity.test.ts
  • tests/integration/responses-ws/custom-server-bootstrap.test.ts
  • tests/integration/responses-ws/http-regression-after-ws.test.ts
  • tests/integration/responses-ws/ingress-delayed-bridging.test.ts
  • tests/integration/responses-ws/outbound-adapter.test.ts
  • tests/integration/responses-ws/provider-testing-backend.test.ts
  • tests/integration/responses-ws/session-continuity.test.ts
  • tests/unit/actions/system-config-save.test.ts
  • tests/unit/lib/config/system-settings-cache.test.ts
  • tests/unit/lib/rate-limit/lease-service.test.ts
  • tests/unit/lib/timezone/timezone-resolver.test.ts
  • tests/unit/provider-test-ws-ui.test.tsx
  • tests/unit/proxy/pricing-no-price.test.ts
  • tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
  • tests/unit/proxy/session.test.ts
  • tests/unit/repository/system-config-update-missing-columns.test.ts
  • tests/unit/responses-ws-frame-schema.test.ts
  • tests/unit/responses-ws-terminal-finalization.test.ts
  • tests/unit/responses-ws-transport-selector.test.ts
  • tests/unit/system-settings-responses-websocket-toggle.test.ts
  • tsconfig.json

Comment on lines +16 to +28
function extractStandaloneNextConfig(serverJsPath) {
if (!fs.existsSync(serverJsPath)) {
throw new Error(`[copy-standalone] Generated server not found: ${serverJsPath}`);
}

const content = fs.readFileSync(serverJsPath, "utf8");
const match = content.match(/const nextConfig = (.+?)\n\nprocess\.env\.__NEXT_PRIVATE_STANDALONE_CONFIG/s);
if (!match) {
throw new Error("[copy-standalone] Failed to extract standalone nextConfig");
}

return JSON.parse(match[1]);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Next.js 15 standalone output format server.js structure

💡 Result:

With output: 'standalone', next build generates a deployable folder at .next/standalone containing:

  • a minimal server.js entrypoint (use this instead of next start)
  • a trimmed node_modules/ (only traced runtime deps)
  • your app’s required server build files under .next/ (e.g., .next/server, manifests, etc.)

Typical shape (approx.):

.next/
  standalone/
    server.js
    package.json
    node_modules/           (subset copied by output file tracing)
    .next/
      server/               (app/server build output + manifests)
      ...

Important details:

  • public/ and .next/static/ are NOT copied into .next/standalone by default. If you want the standalone server to serve them (instead of a CDN), copy them manually into:
    • .next/standalone/public
    • .next/standalone/.next/static
      (Next.js docs even show the cp -r public ... && cp -r .next/static ... pattern.) [1]
  • Run it with:
    • node .next/standalone/server.js [1]
    • and you can set PORT / HOSTNAME via env vars (documented in the same page). [1]
  • The generated server.js historically parsed the port like:
    const currentPort = parseInt(process.env.PORT, 10) || 3000 (relevant if your platform provides non-numeric ports / named pipes). [2]

Monorepo note (why “structure” sometimes looks nested/weird):

  • In Turborepo/monorepos you often must set outputFileTracingRoot to the repo root so Next can trace/copy workspace deps correctly, and you should run the generated .next/standalone/server.js (don’t replace it with a custom server that require('next')). [3]

Sources: Next.js output docs (last updated Feb 27, 2026) [1], Vercel/Next.js discussion about the generated server.js port parsing [2], Vercel/Next.js discussion on standalone + monorepos (outputFileTracingRoot, using generated server) [3].


🏁 Script executed:

# First, let's examine the script file
cat -n scripts/copy-version-to-standalone.cjs

Repository: ding113/claude-code-hub

Length of output: 2560


🏁 Script executed:

# Check the package.json for Next.js version
cat -n package.json | grep -A 5 -B 5 '"next"'

Repository: ding113/claude-code-hub

Length of output: 392


🏁 Script executed:

# Search for where extractStandaloneNextConfig is used
rg "extractStandaloneNextConfig" --context=5

Repository: ding113/claude-code-hub

Length of output: 1740


JSON.parse 缺少错误处理,regex 依赖 Next.js 内部实现。

extractStandaloneNextConfig 通过特定格式的正则表达式提取配置,且依赖于 Next.js 的内部 API __NEXT_PRIVATE_STANDALONE_CONFIG。这个私有 API 在 Next.js 版本更新时可能改变。同时,JSON.parse(match[1]) 缺少错误处理,如果提取的内容非有效 JSON 将导致脚本崩溃。

建议为 JSON.parse 添加 try-catch 捕获,提供更清晰的错误信息:

建议改进
 function extractStandaloneNextConfig(serverJsPath) {
   if (!fs.existsSync(serverJsPath)) {
     throw new Error(`[copy-standalone] Generated server not found: ${serverJsPath}`);
   }

   const content = fs.readFileSync(serverJsPath, "utf8");
   const match = content.match(/const nextConfig = (.+?)\n\nprocess\.env\.__NEXT_PRIVATE_STANDALONE_CONFIG/s);
   if (!match) {
     throw new Error("[copy-standalone] Failed to extract standalone nextConfig");
   }

-  return JSON.parse(match[1]);
+  try {
+    return JSON.parse(match[1]);
+  } catch (e) {
+    throw new Error(`[copy-standalone] Failed to parse nextConfig JSON: ${e.message}`);
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function extractStandaloneNextConfig(serverJsPath) {
if (!fs.existsSync(serverJsPath)) {
throw new Error(`[copy-standalone] Generated server not found: ${serverJsPath}`);
}
const content = fs.readFileSync(serverJsPath, "utf8");
const match = content.match(/const nextConfig = (.+?)\n\nprocess\.env\.__NEXT_PRIVATE_STANDALONE_CONFIG/s);
if (!match) {
throw new Error("[copy-standalone] Failed to extract standalone nextConfig");
}
return JSON.parse(match[1]);
}
function extractStandaloneNextConfig(serverJsPath) {
if (!fs.existsSync(serverJsPath)) {
throw new Error(`[copy-standalone] Generated server not found: ${serverJsPath}`);
}
const content = fs.readFileSync(serverJsPath, "utf8");
const match = content.match(/const nextConfig = (.+?)\n\nprocess\.env\.__NEXT_PRIVATE_STANDALONE_CONFIG/s);
if (!match) {
throw new Error("[copy-standalone] Failed to extract standalone nextConfig");
}
try {
return JSON.parse(match[1]);
} catch (e) {
throw new Error(`[copy-standalone] Failed to parse nextConfig JSON: ${e.message}`);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/copy-version-to-standalone.cjs` around lines 16 - 28, The function
extractStandaloneNextConfig currently relies on a fragile regex that targets
Next.js's private symbol __NEXT_PRIVATE_STANDALONE_CONFIG and calls
JSON.parse(match[1]) without error handling; wrap the JSON.parse in a try-catch
inside extractStandaloneNextConfig to catch syntax errors and rethrow a clearer
error that includes the extracted snippet and a hint about Next.js internal
format changes, and consider making the regex extraction more defensive (e.g.,
trim the captured group and validate it looks like JSON before parsing) so
failures surface with actionable messages instead of crashing.

Comment on lines +393 to +412
{/* Enable Responses WebSocket */}
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex items-center justify-between hover:bg-white/[0.04] transition-colors">
<div className="flex items-start gap-3">
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400 shrink-0">
<Network className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-foreground">{t("enableResponsesWebSocket")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("enableResponsesWebSocketDesc")}
</p>
</div>
</div>
<Switch
id="enable-responses-websocket"
checked={enableResponsesWebSocket}
onCheckedChange={(checked) => setEnableResponsesWebSocket(checked)}
disabled={isPending}
/>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

为新开关补上可访问名称。

这里的可见文案没有和 Switch 建立程序化关联,读屏时大概率只会读出“switch”而不知道它控制什么。请给这个开关补 Label htmlFor,或至少加上 aria-labelledby / aria-describedby。这会直接影响辅助技术用户完成配置。

可参考的修复方式
         <div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex items-center justify-between hover:bg-white/[0.04] transition-colors">
           <div className="flex items-start gap-3">
             <div className="w-8 h-8 flex items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-400 shrink-0">
               <Network className="h-4 w-4" />
             </div>
             <div>
-              <p className="text-sm font-medium text-foreground">{t("enableResponsesWebSocket")}</p>
-              <p className="text-xs text-muted-foreground mt-0.5">
+              <Label
+                htmlFor="enable-responses-websocket"
+                className="text-sm font-medium text-foreground"
+              >
+                {t("enableResponsesWebSocket")}
+              </Label>
+              <p
+                id="enable-responses-websocket-description"
+                className="text-xs text-muted-foreground mt-0.5"
+              >
                 {t("enableResponsesWebSocketDesc")}
               </p>
             </div>
           </div>
           <Switch
             id="enable-responses-websocket"
+            aria-describedby="enable-responses-websocket-description"
             checked={enableResponsesWebSocket}
             onCheckedChange={(checked) => setEnableResponsesWebSocket(checked)}
             disabled={isPending}
           />
         </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/config/_components/system-settings-form.tsx around
lines 393 - 412, The Switch with id "enable-responses-websocket" lacks an
accessible label; update the JSX around the Switch (the block rendering
enableResponsesWebSocket, t("enableResponsesWebSocket"),
t("enableResponsesWebSocketDesc")) to programmatically associate the visible
text with the control — e.g., add a <label htmlFor="enable-responses-websocket">
(or add aria-labelledby referencing the title/description IDs and
aria-describedby for the description) so the Switch (id
"enable-responses-websocket") is announced properly; keep the existing
checked={enableResponsesWebSocket},
onCheckedChange={setEnableResponsesWebSocket} and disabled={isPending} props
unchanged.

Comment on lines +55 to +74
export function toResponsesWebSocketUrl(upstreamUrl: string): string | null {
try {
const url = new URL(upstreamUrl);
if (url.protocol === "http:") {
url.protocol = "ws:";
} else if (url.protocol === "https:") {
url.protocol = "wss:";
} else if (url.protocol !== "ws:" && url.protocol !== "wss:") {
return null;
}

if (!url.pathname.includes("/responses")) {
return null;
}

return url.toString();
} catch {
return null;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

端点校验过宽,fallback reason 也会被误分。

Line 66 的 includes("/responses") 会把 /v1/responses-debug/foo/responses-extra 这类路径也当成可用 WS 端点,导致错误地优先走 WebSocket。反过来,Line 133 这里只按 http(s) 前缀推断原因,所以像 wss://host/v1/chat/completions 这样的 URL 会被标成 unsupported_protocol,实际问题是端点不支持。这样 transport 决策和 websocketFallbackReason 观测都会被带偏,建议先解析一次 URL,再分别校验“协议是否受支持”和“最后一个 path segment 是否为 responses”。

Also applies to: 131-136

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/v1/_lib/proxy/responses-ws-transport.ts` around lines 55 - 74, The
current toResponsesWebSocketUrl implementation uses
pathname.includes("/responses") which incorrectly matches substrings like
"/responses-debug"; change to parse URL.pathname, split by "/", ignore empty
segments, and require the last segment === "responses". Also separate protocol
validation from path validation: in toResponsesWebSocketUrl first
check/normalize supported protocols (http->ws, https->wss, allow ws/wss,
otherwise return null) and then validate the final path segment; apply the same
exact checks in the code that sets websocketFallbackReason so it distinguishes
unsupported_protocol vs unsupported_path (e.g., use symbols to indicate
'unsupported_protocol' when protocol unsupported and 'unsupported_endpoint' or
'unsupported_path' when last path segment !== "responses").

Comment on lines +173 to +181
.map((item, index: number) => {
const dataKeyStr = String(item.dataKey);
const key = `${nameKey || item.name || dataKeyStr || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;

return (
<div
key={item.dataKey}
key={dataKeyStr}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

React key 可能不唯一。

使用 dataKeyStr 作为 React 列表的 key 可能导致重复 key 问题,因为多个 payload 项可能具有相同的 dataKey。建议结合 index 确保唯一性。

建议的修复方案
          .map((item, index: number) => {
              const dataKeyStr = String(item.dataKey);
              const key = `${nameKey || item.name || dataKeyStr || "value"}`;
              const itemConfig = getPayloadConfigFromPayload(config, item, key);
              const indicatorColor = color || item.payload?.fill || item.color;

              return (
                <div
-                  key={dataKeyStr}
+                  key={`${dataKeyStr}-${index}`}
                  className={cn(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/chart.tsx` around lines 173 - 181, The current map uses
dataKeyStr as the React list key which can be non-unique; update the key
generation inside the map in src/components/ui/chart.tsx (the .map((item, index)
=> { ... }) block) to guarantee uniqueness by combining dataKeyStr with the
index or the computed key variable (e.g., use `${dataKeyStr}-${index}` or
`${key}-${index}`), so replace the existing key={dataKeyStr} with a
deterministic unique key that uses index and/or the computed key/nameKey to
avoid duplicate keys.

@@ -0,0 +1,255 @@
import http from "node:http";
import net from "node:net";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C2 'getFreePort|server\.listen\(port, "127\.0\.0\.1", \(error\)' tests/integration/responses-ws/ingress-delayed-bridging.test.ts

Repository: ding113/claude-code-hub

Length of output: 435


🌐 Web query:

In Node.js, does http.Server.listen(0, host, callback) allocate an ephemeral port, and are bind failures emitted via the "error" event rather than passed to the callback?

💡 Result:

Yes.

  • Ephemeral port allocation: Passing 0 as the port (e.g., httpServer.listen(0, host, cb)) tells Node to let the operating system choose an arbitrary unused port, which you can read back via server.address().port after the server is listening. [1]
  • Bind/listen failures: The callback you pass to listen(...) is not an error-first callback; it’s attached to the server’s 'listening' event (i.e., it runs only on successful bind). Bind failures like EADDRINUSE, permission errors, etc., are delivered by emitting the server’s 'error' event instead. [1][2]

Sources:
[1] Node.js net.Server.listen([port[, host[, backlog]]][, callback]) docs (port 0, callback is 'listening', errors via 'error', EADDRINUSE example). (nodejs.org)
[2] Node.js http server listen(...) docs (callback added as listener for 'listening'). (nodejs.org)

Citations:


🏁 Script executed:

cat -n tests/integration/responses-ws/ingress-delayed-bridging.test.ts | head -100

Repository: ding113/claude-code-hub

Length of output: 3543


🏁 Script executed:

sed -n '68,100p' tests/integration/responses-ws/ingress-delayed-bridging.test.ts

Repository: ding113/claude-code-hub

Length of output: 904


应直接在真实 server 上监听 0 端口,而不是先占端口再重开。

当前实现在 getFreePort() 关闭临时服务器后、真实服务器在第 71 行调用 listen(port) 之前存在 TOCTOU 竞争窗口。若该端口被其他进程占用,真实服务器会通过 'error' 事件而非回调参数接收到 EADDRINUSE 错误。由于第 71 行的 Promise 中没有为真实 server 注册错误处理器,绑定失败时 Promise 会无限挂起而非快速失败。直接对真实 http.Server 调用 listen(0),再从 server.address() 读取系统分配的端口,可消除此竞争窗口。

建议修改
-import net from "node:net";
 ...
-async function getFreePort() {
-  return new Promise((resolve, reject) => {
-    const server = net.createServer();
-    server.listen(0, "127.0.0.1", () => {
-      const address = server.address();
-      if (!address || typeof address === "string") {
-        reject(new Error("Failed to allocate free port"));
-        return;
-      }
-      const { port } = address;
-      server.close((error) => (error ? reject(error) : resolve(port)));
-    });
-    server.on("error", reject);
-  });
-}
 ...
-  const port = await getFreePort();
-  await new Promise((resolve, reject) => {
-    server.listen(port, "127.0.0.1", (error) => (error ? reject(error) : resolve(undefined)));
-  });
+  await new Promise<void>((resolve, reject) => {
+    server.once("error", reject);
+    server.listen(0, "127.0.0.1", () => resolve());
+  });
+  const address = server.address();
+  if (!address || typeof address === "string") {
+    throw new Error("Failed to allocate free port");
+  }
+  const { port } = address;

Also applies to: 6-19, 69-72

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/ingress-delayed-bridging.test.ts` at line 2,
The test currently uses getFreePort() to reserve a port then closes the temp
server and calls realServer.listen(port), creating a TOCTOU race; instead, have
the real http.Server call listen(0) directly and then read the assigned port
from server.address(). Specifically, remove the temp port-reservation flow
(getFreePort and its temporary net.createServer usage) and change the real
server listen call to server.listen(0) (where the code referenced as the real
server and the Promise around listen is created), then after listening resolve
the Promise using (server.address() as AddressInfo).port; also ensure the
Promise registers an 'error' handler on the real server to reject on listen
errors to avoid hanging.

Comment on lines +121 to +136
it("extends idle window for flex service tier", () => {
const timeoutProfile = resolveResponsesWsTimeoutProfile({
frame: {
type: "response.create",
response: {
model: "gpt-5-codex",
service_tier: "flex",
},
},
handshakeTimeoutMs: 1000,
firstEventTimeoutMs: 5000,
});

expect(timeoutProfile.handshakeTimeoutMs).toBe(1000);
expect(timeoutProfile.firstEventTimeoutMs).toBeGreaterThan(5000);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 查找 resolveResponsesWsTimeoutProfile 的实现,确认 flex tier 的扩展逻辑
ast-grep --pattern $'function resolveResponsesWsTimeoutProfile($_) {
  $$$
}'

# 备选:搜索函数定义
rg -n -A 20 'resolveResponsesWsTimeoutProfile' --type ts -g '!*.test.ts'

Repository: ding113/claude-code-hub

Length of output: 3648


🏁 Script executed:

rg -n 'FLEX_FIRST_EVENT_TIMEOUT_MS' --type ts

Repository: ding113/claude-code-hub

Length of output: 284


验证扩展超时值的准确性。

flex 服务层的超时扩展采用最小值强制机制(Math.max(requestedFirstEventTimeoutMs, FLEX_FIRST_EVENT_TIMEOUT_MS),其中 FLEX_FIRST_EVENT_TIMEOUT_MS = 60_000),而非扩展系数。测试断言只验证了超时值大于输入的 5000ms,建议补充对具体的 60_000ms 超时值的验证,以确保 flex 层级的扩展逻辑符合预期。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/outbound-adapter.test.ts` around lines 121 -
136, The test currently only asserts the flex-tier firstEventTimeoutMs is
greater than 5000ms; update it to assert the resolved timeout matches the
minimum flex floor by checking that timeoutProfile.firstEventTimeoutMs is at
least FLEX_FIRST_EVENT_TIMEOUT_MS (or equals 60000) to verify the Math.max logic
in resolveResponsesWsTimeoutProfile; reference the
resolveResponsesWsTimeoutProfile function and the FLEX_FIRST_EVENT_TIMEOUT_MS
constant when adding the explicit assertion.

Comment on lines +240 to +276
it("fails mid-stream disconnect without implicit replay", async () => {
const port = await getFreePort();
const wss = new WebSocketServer({ port, host: "127.0.0.1" });
wss.on("connection", (socket) => {
socket.once("message", () => {
socket.send(
JSON.stringify({
type: "response.created",
response: {
id: "resp_1",
object: "response",
created: 1,
model: "gpt-5-codex",
status: "in_progress",
},
})
);
socket.close();
});
});

const response = await sendResponsesWsRequest({
websocketUrl: `ws://127.0.0.1:${port}/v1/responses`,
frame: {
type: "response.create",
response: {
model: "gpt-5-codex",
},
},
isStreaming: true,
handshakeTimeoutMs: 1000,
firstEventTimeoutMs: 1000,
});

await expect(response.text()).rejects.toThrow("closed before terminal event");
await new Promise((resolve) => wss.close(() => resolve(undefined)));
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

WebSocketServer 资源清理存在风险。

如果测试在执行到第 275 行之前失败(例如断言失败),wss 将不会被关闭,可能导致端口泄漏或影响后续测试。建议参照第二个 describe 块的模式,使用 afterEach 进行统一清理。

建议的修复方案
 describe("responses websocket session continuity", () => {
+  let wss: WebSocketServer | null = null;
+
+  afterEach(async () => {
+    if (wss) {
+      await new Promise((resolve) => wss?.close(() => resolve(undefined)));
+      wss = null;
+    }
+  });
+
   it("rebinds session from prompt_cache_key terminal event", async () => {
     // ... 保持不变
   });

   it("fails mid-stream disconnect without implicit replay", async () => {
     const port = await getFreePort();
-    const wss = new WebSocketServer({ port, host: "127.0.0.1" });
+    wss = new WebSocketServer({ port, host: "127.0.0.1" });
     wss.on("connection", (socket) => {
       // ...
     });

     // ... 测试逻辑 ...

     await expect(response.text()).rejects.toThrow("closed before terminal event");
-    await new Promise((resolve) => wss.close(() => resolve(undefined)));
   });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/responses-ws/session-continuity.test.ts` around lines 240 -
276, The test creates a WebSocketServer named wss that may not be closed if the
test fails; add centralized cleanup (e.g., an afterEach) similar to the other
describe block to always close/await any created servers. Concretely, track
created servers (or the wss variable) in the surrounding describe scope and in
afterEach call wss.close() (await the close callback or wrap in a Promise) to
ensure the socket is closed and the port freed even when the test throws; update
the session-continuity.test.ts tests that create WebSocketServer instances to
use that shared cleanup.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/ui/chart.tsx`:
- Around line 235-241: The JSX conditional currently uses a truthy check
"item.value &&" which hides valid numeric zero; update the render condition to
explicitly check for null/undefined (use "item.value != null") where the span is
rendered so that 0 is displayed; locate the block that renders the span for
{item.value && ( ... )} in the chart component and replace that condition with
the null/undefined guard.

In `@tsconfig.json`:
- Around line 33-34: The tsconfig includes an invalid duplicated path
".next/dev/dev/types/**/*.ts"; remove that entry from the "include"/paths array
so only the correct Next.js generated type paths remain (e.g. keep
".next/dev/types/**/*.ts" and ".next/types/**/*.ts" where present). Locate the
string ".next/dev/dev/types/**/*.ts" in tsconfig.json and delete that array
element to fix the typo.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ecb633f8-ed0f-46f2-bf50-6ace01df9871

📥 Commits

Reviewing files that changed from the base of the PR and between c6f518d and 0259b73.

📒 Files selected for processing (2)
  • src/components/ui/chart.tsx
  • tsconfig.json

Comment on lines +235 to +241
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

值为 0 时不会渲染。

当前条件 item.value && 使用真值检查,当 item.value0 时条件为假,导致有效的数值 0 不会被显示。应改用 != null 检查以正确处理所有数值。

建议的修复方案
-                     {item.value && (
+                     {item.value != null && (
                        <span className="text-foreground font-mono font-medium tabular-nums">
                          {typeof item.value === "number"
                            ? item.value.toLocaleString()
                            : String(item.value)}
                        </span>
                      )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/chart.tsx` around lines 235 - 241, The JSX conditional
currently uses a truthy check "item.value &&" which hides valid numeric zero;
update the render condition to explicitly check for null/undefined (use
"item.value != null") where the span is rendered so that 0 is displayed; locate
the block that renders the span for {item.value && ( ... )} in the chart
component and replace that condition with the null/undefined guard.

Comment on lines +33 to +34
".next/dev/types/**/*.ts",
".next/dev/dev/types/**/*.ts"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: 检查 .next 目录下的 types 子目录结构

# 查找 .next 目录下所有包含 types 的路径
fd -t d 'types' .next 2>/dev/null || echo ".next directory not found or no types directories exist"

# 列出 .next 目录结构(如果存在)
if [ -d ".next" ]; then
  tree -d -L 4 .next 2>/dev/null || find .next -type d -maxdepth 4 2>/dev/null
fi

Repository: ding113/claude-code-hub

Length of output: 122


🏁 Script executed:

cat -n tsconfig.json | head -50

Repository: ding113/claude-code-hub

Length of output: 1145


🌐 Web query:

Next.js .next/dev/types directory structure generated files

💡 Result:

In recent Next.js versions, route-related TypeScript artifacts are generated into the build output directory (distDir, default .next) when you run next dev, next build, or next typegen. The output is written to:

  • Dev (commonly): .next/dev/types/
  • Build/typegen (commonly): .next/types/ (and next typegen writes to <distDir>/types) [1]

What you’ll typically find under .next/dev/types/

1) routes.d.ts

A generated declaration file containing the set of valid route strings in your app, used to type href for next/link and navigation APIs (typed routes). Next.js generates this kind of “hidden .d.ts file inside .next” during next dev / next build so the TS compiler can flag invalid links in the editor/CI. [2][3]

2) app/** (mirrors your app/ router tree)

A folder tree that mirrors your app/ directory structure (including route groups, dynamic segments, etc.), containing generated .ts type-check “stubs” for each route segment file (commonly page.ts, layout.ts, and other special files). These generated files are what show up in many build/type errors as paths like .next/types/app/.../page.ts. [4][3]

They exist so Next.js can:

  • validate the shape of your exported functions (e.g., generateMetadata, generateStaticParams, etc.) against the route they belong to, and
  • provide globally available helpers like PageProps<'/some/route'> after type generation. [3]

3) Optional: environment variable typings

If you enable experimental.typedEnv, Next.js generates a .d.ts under .next/types with typed env keys for editor IntelliSense. [2]

Notes

  • The exact contents can vary by Next.js version and features (App Router vs Pages Router, typed env, etc.), but it’s all generated output and is safe to delete (it will be regenerated on the next run).
  • If you run tsc --noEmit without having generated these files, you may miss route-type checking; next typegen exists specifically to generate them without a full build. [1]

Sources: Next.js CLI (next typegen) output location [1], Next.js TypeScript docs on generated route .d.ts and typed env [2], Next.js page docs noting types are generated by next dev/build/typegen and that helpers become available after generation [3], and examples of generated .next/types/app/... files showing up in type errors [4].

[1] (nextjsjp.org)
[2] (nextjs.org)
[3] (nextjs.org)
[4] (nextjs-forum.com)

Citations:


第 34 行的路径 .next/dev/dev/types/**/*.ts 包含重复的 dev/dev,这不是有效的 Next.js 路径。

Next.js 的官方文档记录的类型生成路径只有两种:

  • .next/dev/types/next dev 期间生成)
  • .next/types/next buildnext typegen 时生成)

第 34 行的 .next/dev/dev/types/**/*.ts 不在任何官方文档中,应为拼写错误。建议删除这一行。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tsconfig.json` around lines 33 - 34, The tsconfig includes an invalid
duplicated path ".next/dev/dev/types/**/*.ts"; remove that entry from the
"include"/paths array so only the correct Next.js generated type paths remain
(e.g. keep ".next/dev/types/**/*.ts" and ".next/types/**/*.ts" where present).
Locate the string ".next/dev/dev/types/**/*.ts" in tsconfig.json and delete that
array element to fix the typo.

const targetOrigin = `http://127.0.0.1:${actualPort}`;
const wss = createResponsesUpgradeServer({ targetOrigin });

server.on("upgrade", (req, socket, head) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CRITICAL] [SECURITY-VULNERABILITY] WebSocket upgrade allows idle unauthenticated connections (DoS risk)

Why this is a problem: server.on("upgrade") upgrades any request hitting /v1/responses, and handleConnection immediately starts a per-socket setInterval ping loop. There is no first-frame deadline for response.create, and protocol errors keep the socket open. An attacker can open many WebSocket connections and send nothing (or invalid frames) to consume sockets/timers and degrade the Node process.

Suggested fix:

// server.js
const FIRST_FRAME_TIMEOUT_MS = 5_000;

function hasAuthHeaders(headers) {
  return (
    typeof headers.authorization === "string" ||
    typeof headers["x-api-key"] === "string" ||
    typeof headers["x-goog-api-key"] === "string"
  );
}

server.on("upgrade", (req, socket, head) => {
  // ...existing URL parsing...
  if (!hasAuthHeaders(req.headers)) {
    socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
    socket.destroy();
    return;
  }

  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit("connection", ws, req);
  });
});

function createResponsesWebSocketConnectionHandler(options) {
  return function handleConnection(socket, request) {
    let firstFrameTimeoutId = setTimeout(() => {
      writeProtocolError(socket, "first_frame_timeout", "Timed out waiting for response.create");
      socket.close(1008);
    }, FIRST_FRAME_TIMEOUT_MS);

    const clearFirstFrameTimeout = () => {
      if (firstFrameTimeoutId) {
        clearTimeout(firstFrameTimeoutId);
        firstFrameTimeoutId = null;
      }
    };
    socket.once("close", clearFirstFrameTimeout);

    socket.on("message", async (raw) => {
      // ...existing JSON parse...
      let createFrame;
      try {
        createFrame = validateCreateFrame(parsedFrame);
        clearFirstFrameTimeout();
      } catch (error) {
        writeProtocolError(
          socket,
          "invalid_request",
          error instanceof Error ? error.message : "Invalid response.create frame"
        );
        socket.close(1008);
        return;
      }

      // ...existing forwarding...
    });
  };
}

state.activeTurn = false;
});

socket.on("error", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [ERROR-SILENT] WebSocket error event is swallowed (no logging)

Why this is a problem: The current handler drops the error object and closes the socket:

socket.on("error", () => {
  socket.close();
});

This makes production debugging of bridge failures much harder (you lose the underlying error cause), and the client gets no actionable signal beyond a disconnect.

Suggested fix:

socket.on("error", (err) => {
  console.error("[CCH] WS connection error", err);

  if (state.activeAbortController) {
    state.activeAbortController.abort();
  }

  socket.close();
});

Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR is XL-sized and introduces a new same-port custom Node server plus WebSocket transport plumbing for /v1/responses. The main blockers are around WebSocket connection hardening: the current upgrade/connection flow can be held open cheaply (idle connections + per-socket ping timer) and some error paths are silent, which makes both DoS resistance and production debugging weaker than necessary.

PR Size: XL

  • Lines changed: 16930
  • Files changed: 70
  • Split suggestions:
      1. server.js + Docker/standalone build changes (Dockerfile, next.config.ts, scripts/copy-version-to-standalone.cjs)
      1. Core proxy WS transport (src/app/v1/_lib/proxy/responses-ws-*.ts, src/app/v1/_lib/proxy/forwarder.ts)
      1. Provider testing + UI/i18n (src/lib/provider-testing/*, src/app/[locale]/*, messages/*)
      1. Tests only (tests/unit/*, tests/integration/responses-ws/*)

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 1 0 0 0
Error Handling 0 1 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

  • server.js:429 [CRITICAL] [SECURITY-VULNERABILITY] WebSocket upgrade allows idle unauthenticated connections (DoS risk). (Confidence: 96)

High Priority Issues (Should Fix)

  • server.js:363 [HIGH] [ERROR-SILENT] WebSocket error event is swallowed (no logging). (Confidence: 88)

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Codex AI

Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the PR size label (size/XL) to PR #882.

Posted 2 inline review comments (diff-only):

  • server.js:429 [CRITICAL] [SECURITY-VULNERABILITY] WS upgrade/connection flow can be held open cheaply (idle connections + per-socket ping timer) → suggested auth gating + first-frame timeout + close-on-protocol-error hardening.
  • server.js:363 [HIGH] [ERROR-SILENT] WS error event is swallowed → suggested logging + aborting active turn before closing.

Submitted the required review summary on PR #882 with the issue counts and XL split suggestions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:i18n area:OpenAI area:provider enhancement New feature or request size/XL Extra Large PR (> 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant