From b7a58ddfc4f64324826bde4c6c787ea64d30bc31 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 00:42:38 +0800 Subject: [PATCH 1/4] feat(ws): add WebSocket toggle, frame schemas, server bootstrap, and transport classifier Wave 1 of OpenAI Responses WebSocket support: - Add enableResponsesWebSocket system setting across all layers (schema, types, repository, cache, actions, UI, 5 locales) mirroring enableHttp2 - Create WS frame Zod schemas (response.create, terminal events, errors) with forward-compatible .passthrough() and byte-for-byte encrypted_content - Add same-port custom Node server (src/server/) wrapping Next.js + ws library for /v1/responses WebSocket upgrades with heartbeat/graceful shutdown - Add transport classifier deciding HTTP vs WebSocket based on toggle, endpoint, provider type, URL scheme, and proxy config - Add ws_fallback provider chain reason mirroring http2_fallback semantics (no circuit breaker penalty, no provider switch) --- Dockerfile | 2 + deploy/Dockerfile | 2 + deploy/Dockerfile.dev | 2 + drizzle/0079_quick_blink.sql | 1 + drizzle/meta/0079_snapshot.json | 3921 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/provider-chain.json | 1 + messages/en/settings/config.json | 2 + messages/ja/provider-chain.json | 1 + messages/ja/settings/config.json | 2 + messages/ru/provider-chain.json | 1 + messages/ru/settings/config.json | 2 + messages/zh-CN/provider-chain.json | 1 + messages/zh-CN/settings/config.json | 2 + messages/zh-TW/provider-chain.json | 1 + messages/zh-TW/settings/config.json | 2 + next.config.ts | 7 +- package.json | 3 + src/actions/system-config.ts | 2 + .../_components/system-settings-form.tsx | 27 + src/app/[locale]/settings/config/page.tsx | 1 + src/app/v1/_lib/proxy/session.ts | 1 + src/app/v1/_lib/proxy/transport-classifier.ts | 74 + src/drizzle/schema.ts | 3 + src/lib/config/system-settings-cache.ts | 13 + src/lib/validation/schemas.ts | 2 + src/lib/ws/frame-parser.ts | 85 + src/lib/ws/frames.ts | 147 + src/repository/_shared/transformers.ts | 1 + src/repository/system-config.ts | 7 + src/server/index.ts | 60 + src/server/ws-manager.ts | 85 + src/types/message.ts | 1 + src/types/system-config.ts | 6 + ...ettings-responses-websocket-toggle.test.ts | 134 + tests/unit/lib/ws/frame-parser.test.ts | 204 + tests/unit/lib/ws/frames.test.ts | 323 ++ tests/unit/proxy/transport-classifier.test.ts | 252 ++ tests/unit/server/ws-manager.test.ts | 274 ++ 39 files changed, 5661 insertions(+), 1 deletion(-) create mode 100644 drizzle/0079_quick_blink.sql create mode 100644 drizzle/meta/0079_snapshot.json create mode 100644 src/app/v1/_lib/proxy/transport-classifier.ts create mode 100644 src/lib/ws/frame-parser.ts create mode 100644 src/lib/ws/frames.ts create mode 100644 src/server/index.ts create mode 100644 src/server/ws-manager.ts create mode 100644 tests/unit/lib/config/system-settings-responses-websocket-toggle.test.ts create mode 100644 tests/unit/lib/ws/frame-parser.test.ts create mode 100644 tests/unit/lib/ws/frames.test.ts create mode 100644 tests/unit/proxy/transport-classifier.test.ts create mode 100644 tests/unit/server/ws-manager.test.ts diff --git a/Dockerfile b/Dockerfile index 8576fbc54..19a1113e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,6 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/VERSION ./VERSION +# TODO: Switch to custom server entry for WebSocket support once ingress handler is ready +# CMD ["node", "src/server/index.js"] CMD ["node", "server.js"] diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2c6596280..99327f347 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -58,4 +58,6 @@ COPY --from=build --chown=node:node /app/.next/static ./.next/static USER node EXPOSE 3000 +# TODO: Switch to custom server entry for WebSocket support once ingress handler is ready +# CMD ["node", "src/server/index.js"] CMD ["node", "server.js"] diff --git a/deploy/Dockerfile.dev b/deploy/Dockerfile.dev index fb7db9b92..fb6e8aeb4 100644 --- a/deploy/Dockerfile.dev +++ b/deploy/Dockerfile.dev @@ -52,4 +52,6 @@ COPY --from=build --chown=node:node /app/.next/static ./.next/static USER node EXPOSE 3000 +# TODO: Switch to custom server entry for WebSocket support once ingress handler is ready +# CMD ["node", "src/server/index.js"] CMD ["node", "server.js"] diff --git a/drizzle/0079_quick_blink.sql b/drizzle/0079_quick_blink.sql new file mode 100644 index 000000000..d609065b8 --- /dev/null +++ b/drizzle/0079_quick_blink.sql @@ -0,0 +1 @@ +ALTER TABLE "system_settings" ADD COLUMN "enable_responses_websocket" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0079_snapshot.json b/drizzle/meta/0079_snapshot.json new file mode 100644 index 000000000..975760969 --- /dev/null +++ b/drizzle/meta/0079_snapshot.json @@ -0,0 +1,3921 @@ +{ + "id": "3e19d283-afb6-4065-985c-2b2ecea6d97f", + "prevId": "aa3f3ed9-db02-48e9-b755-e2dd39b0b77a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_responses_websocket": { + "name": "enable_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 662a127c7..dccc303fe 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1772782546382, "tag": "0078_remarkable_lionheart", "breakpoints": true + }, + { + "idx": 79, + "version": "7", + "when": 1772986897101, + "tag": "0079_quick_blink", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 67ba8dd64..5f26f39db 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -52,6 +52,7 @@ "client_error_non_retryable": "Client Error", "concurrent_limit_failed": "Concurrent Limit", "http2_fallback": "HTTP/2 Fallback", + "ws_fallback": "WebSocket Fallback", "session_reuse": "Session Reuse", "initial_selection": "Initial Selection", "endpoint_pool_exhausted": "Endpoint Pool Exhausted", diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index bb192966d..962833f58 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -47,6 +47,8 @@ "enableAutoCleanupDesc": "Automatically clean up historical log data on schedule", "enableHttp2": "Enable HTTP/2", "enableHttp2Desc": "When enabled, proxy requests will prefer HTTP/2 protocol. Automatically falls back to HTTP/1.1 on failure.", + "enableResponsesWebSocket": "Enable Responses WebSocket", + "enableResponsesWebSocketDesc": "When enabled, /v1/responses requests will attempt WebSocket transport first, falling back to HTTP if WebSocket setup fails.", "enableResponseFixer": "Enable Response Fixer", "enableResponseFixerDesc": "Automatically repairs common upstream response issues (encoding, SSE, truncated JSON). Enabled by default.", "enableThinkingSignatureRectifier": "Enable Thinking Signature Rectifier", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 701a2a4b9..d04a1e54a 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -52,6 +52,7 @@ "client_error_non_retryable": "クライアントエラー", "concurrent_limit_failed": "同時実行制限", "http2_fallback": "HTTP/2 フォールバック", + "ws_fallback": "WebSocket フォールバック", "session_reuse": "セッション再利用", "initial_selection": "初期選択", "endpoint_pool_exhausted": "エンドポイントプール枯渇", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index abd1ad89a..13aa0e40a 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -47,6 +47,8 @@ "enableAutoCleanupDesc": "スケジュールに従って履歴ログを自動的にクリーンアップします", "enableHttp2": "HTTP/2 を有効にする", "enableHttp2Desc": "有効にすると、プロキシ要求は優先的に HTTP/2 を使用します。HTTP/2 が失敗した場合は自動的に HTTP/1.1 にフォールバックします。", + "enableResponsesWebSocket": "Responses WebSocket を有効にする", + "enableResponsesWebSocketDesc": "有効にすると、/v1/responses リクエストは WebSocket トランスポートを優先使用し、WebSocket の確立に失敗した場合は HTTP にフォールバックします。", "enableResponseFixer": "レスポンス整流を有効化", "enableResponseFixerDesc": "上流応答の一般的な形式問題(エンコーディング、SSE、途切れた JSON)を自動修復します(既定で有効)。", "enableThinkingSignatureRectifier": "thinking 署名整流を有効化", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index ebe5d8629..10087afd3 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -52,6 +52,7 @@ "client_error_non_retryable": "Ошибка клиента", "concurrent_limit_failed": "Лимит параллельных запросов", "http2_fallback": "Откат HTTP/2", + "ws_fallback": "Откат WebSocket", "session_reuse": "Повторное использование сессии", "initial_selection": "Первоначальный выбор", "endpoint_pool_exhausted": "Пул конечных точек исчерпан", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 00d6b4805..b892f85c1 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -47,6 +47,8 @@ "enableAutoCleanupDesc": "Автоматически очищать исторические логи по расписанию", "enableHttp2": "Включить HTTP/2", "enableHttp2Desc": "При включении прокси-запросы будут отдавать приоритет HTTP/2. Если HTTP/2 не удастся, произойдёт автоматическое понижение до HTTP/1.1.", + "enableResponsesWebSocket": "Включить Responses WebSocket", + "enableResponsesWebSocketDesc": "При включении запросы /v1/responses будут сначала использовать WebSocket-транспорт, с откатом на HTTP при неудаче установки WebSocket.", "enableResponseFixer": "Включить исправление ответов", "enableResponseFixerDesc": "Автоматически исправляет распространённые проблемы ответа у провайдеров (кодировка, SSE, обрезанный JSON). Включено по умолчанию.", "enableThinkingSignatureRectifier": "Включить исправление thinking-signature", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index eecf293af..5c0a3e871 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -52,6 +52,7 @@ "client_error_non_retryable": "客户端错误", "concurrent_limit_failed": "并发限制", "http2_fallback": "HTTP/2 回退", + "ws_fallback": "WebSocket 回退", "session_reuse": "会话复用", "initial_selection": "首次选择", "endpoint_pool_exhausted": "端点池耗尽", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 981b1fd34..2b4130bb4 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -36,6 +36,8 @@ "verboseProviderErrorDesc": "开启后,当所有供应商不可用时返回详细错误信息(包含供应商数量、限流原因等);关闭后仅返回简洁错误码。", "enableHttp2": "启用 HTTP/2", "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", + "enableResponsesWebSocket": "启用 Responses WebSocket", + "enableResponsesWebSocketDesc": "启用后,/v1/responses 请求将优先使用 WebSocket 传输,WebSocket 建立失败时自动回退到 HTTP。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "开启后,识别到 Claude Code 的 Warmup 探测请求将由 CCH 直接抢答短响应,避免访问上游供应商;该请求会记录在日志中,但不计费、不限流、不计入统计。", "enableThinkingSignatureRectifier": "启用 thinking 签名整流器", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 9ce531b7e..511e6a4dc 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -52,6 +52,7 @@ "client_error_non_retryable": "客戶端錯誤", "concurrent_limit_failed": "並發限制", "http2_fallback": "HTTP/2 回退", + "ws_fallback": "WebSocket 回退", "session_reuse": "會話複用", "initial_selection": "首次選擇", "endpoint_pool_exhausted": "端點池耗盡", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index d2df54956..177b33dcd 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -47,6 +47,8 @@ "enableAutoCleanupDesc": "定時自動清理歷史日誌資料", "enableHttp2": "啟用 HTTP/2", "enableHttp2Desc": "啟用後,代理請求將優先使用 HTTP/2 協定;若 HTTP/2 失敗,將自動降級為 HTTP/1.1。", + "enableResponsesWebSocket": "啟用 Responses WebSocket", + "enableResponsesWebSocketDesc": "啟用後,/v1/responses 請求將優先使用 WebSocket 傳輸,WebSocket 建立失敗時自動回退到 HTTP。", "enableResponseFixer": "啟用回應整流", "enableResponseFixerDesc": "自動修復上游回應中常見的編碼、SSE 與 JSON 格式問題(預設開啟)。", "enableThinkingSignatureRectifier": "啟用 thinking 簽名整流器", diff --git a/next.config.ts b/next.config.ts index f3e726bfd..fc438213d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,13 +21,18 @@ const nextConfig: NextConfig = { "ioredis", "postgres", "drizzle-orm", + "ws", ], // 强制包含 undici 和 fetch-socks 到 standalone 输出 // Next.js 依赖追踪无法正确追踪动态导入和类型导入的传递依赖 // 参考: https://nextjs.org/docs/app/api-reference/config/next-config-js/output outputFileTracingIncludes: { - "/**": ["./node_modules/undici/**/*", "./node_modules/fetch-socks/**/*"], + "/**": [ + "./node_modules/undici/**/*", + "./node_modules/fetch-socks/**/*", + "./node_modules/ws/**/*", + ], }, // 文件上传大小限制(用于数据库备份导入) diff --git a/package.json b/package.json index 519e1c55e..0aa7fbafe 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "next dev --port 13500", "build": "next build && (node scripts/copy-version-to-standalone.cjs || bun scripts/copy-version-to-standalone.cjs)", "start": "next start", + "start:ws": "node --import tsx src/server/index.ts", "lint": "biome check .", "lint:fix": "biome check --write .", "typecheck": "tsgo -p tsconfig.json --noEmit", @@ -108,6 +109,7 @@ "tw-animate-css": "^1", "undici": "^7", "vaul": "^1.1.2", + "ws": "^8.19.0", "zod": "^4" }, "devDependencies": { @@ -119,6 +121,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", + "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20251219.1", "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index e1ed4be88..4f5ac316e 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -55,6 +55,7 @@ export async function saveSystemSettings(formData: { enableClientVersionCheck?: boolean; verboseProviderError?: boolean; enableHttp2?: boolean; + enableResponsesWebSocket?: boolean; interceptAnthropicWarmupRequests?: boolean; enableThinkingSignatureRectifier?: boolean; enableThinkingBudgetRectifier?: boolean; @@ -91,6 +92,7 @@ export async function saveSystemSettings(formData: { enableClientVersionCheck: validated.enableClientVersionCheck, verboseProviderError: validated.verboseProviderError, enableHttp2: validated.enableHttp2, + enableResponsesWebSocket: validated.enableResponsesWebSocket, interceptAnthropicWarmupRequests: validated.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: validated.enableThinkingBudgetRectifier, diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index de146da9d..434a7c860 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -11,6 +11,7 @@ import { Pencil, Terminal, Thermometer, + Wifi, Wrench, Zap, } from "lucide-react"; @@ -53,6 +54,7 @@ interface SystemSettingsFormProps { | "timezone" | "verboseProviderError" | "enableHttp2" + | "enableResponsesWebSocket" | "interceptAnthropicWarmupRequests" | "enableThinkingSignatureRectifier" | "enableBillingHeaderRectifier" @@ -96,6 +98,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) initialSettings.verboseProviderError ); const [enableHttp2, setEnableHttp2] = useState(initialSettings.enableHttp2); + const [enableResponsesWebSocket, setEnableResponsesWebSocket] = useState( + initialSettings.enableResponsesWebSocket + ); const [interceptAnthropicWarmupRequests, setInterceptAnthropicWarmupRequests] = useState( initialSettings.interceptAnthropicWarmupRequests ); @@ -169,6 +174,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) timezone, verboseProviderError, enableHttp2, + enableResponsesWebSocket, interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier, enableBillingHeaderRectifier, @@ -384,6 +390,27 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) /> + {/* Enable Responses WebSocket */} +
+
+
+ +
+
+

{t("enableResponsesWebSocket")}

+

+ {t("enableResponsesWebSocketDesc")} +

+
+
+ setEnableResponsesWebSocket(checked)} + disabled={isPending} + /> +
+ {/* Intercept Anthropic Warmup Requests */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 75bef5427..29370cb15 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -47,6 +47,7 @@ async function SettingsConfigContent() { timezone: settings.timezone, verboseProviderError: settings.verboseProviderError, enableHttp2: settings.enableHttp2, + enableResponsesWebSocket: settings.enableResponsesWebSocket, interceptAnthropicWarmupRequests: settings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: settings.enableThinkingBudgetRectifier, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 67afc2ef5..3c4030ca5 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -451,6 +451,7 @@ export class ProxySession { | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) + | "ws_fallback" // WebSocket 传输错误,回退到 HTTP(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(strict endpoint policy 阻止了 fallback) | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 | "client_restriction_filtered"; // 供应商因客户端限制被跳过(会话复用路径) diff --git a/src/app/v1/_lib/proxy/transport-classifier.ts b/src/app/v1/_lib/proxy/transport-classifier.ts new file mode 100644 index 000000000..4127f2f72 --- /dev/null +++ b/src/app/v1/_lib/proxy/transport-classifier.ts @@ -0,0 +1,74 @@ +import "server-only"; + +import { isResponsesWebSocketEnabled } from "@/lib/config/system-settings-cache"; +import type { Provider } from "@/types/provider"; + +import type { ProxySession } from "./session"; + +export type TransportType = "http" | "websocket"; + +export interface TransportDecision { + transport: TransportType; + /** Why this transport was chosen */ + reason: string; +} + +/** + * Classify whether a request should use WebSocket or HTTP transport. + * + * WebSocket is eligible when ALL conditions are met: + * 1. Global enableResponsesWebSocket toggle is ON + * 2. The request targets /v1/responses endpoint + * 3. The provider type is "codex" (Responses API providers) + * 4. The provider URL supports wss:// (https:// base URL) + * 5. No proxy is configured (WS through HTTP proxy is unreliable in v1) + * + * If any condition fails, HTTP is used with no penalty. + */ +export async function classifyTransport( + session: ProxySession, + provider: Provider +): Promise { + // 1. Global toggle + const wsEnabled = await isResponsesWebSocketEnabled(); + if (!wsEnabled) { + return { transport: "http", reason: "websocket_disabled" }; + } + + // 2. Endpoint check - must be /v1/responses + const pathname = session.requestUrl.pathname; + if (!pathname.endsWith("/responses")) { + return { transport: "http", reason: "not_responses_endpoint" }; + } + + // 3. Provider type must be codex + if (provider.providerType !== "codex") { + return { transport: "http", reason: "provider_type_not_codex" }; + } + + // 4. Provider URL must be HTTPS (for wss://) + if (!provider.url || !provider.url.startsWith("https://")) { + return { transport: "http", reason: "provider_url_not_https" }; + } + + // 5. No proxy configured (v1 limitation) + if (provider.proxyUrl) { + return { transport: "http", reason: "proxy_configured" }; + } + + return { transport: "websocket", reason: "all_conditions_met" }; +} + +/** + * Convert an HTTPS provider URL to WSS URL for Responses WebSocket. + * Example: https://api.openai.com -> wss://api.openai.com/v1/responses + */ +export function toWebSocketUrl(providerBaseUrl: string): string { + const url = new URL(providerBaseUrl); + url.protocol = "wss:"; + // Ensure path ends with /v1/responses + if (!url.pathname.endsWith("/v1/responses")) { + url.pathname = url.pathname.replace(/\/$/, "") + "/v1/responses"; + } + return url.toString(); +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ba354ea2a..5813f1298 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -691,6 +691,9 @@ export const systemSettings = pgTable('system_settings', { // 启用 HTTP/2 连接供应商(默认关闭,启用后自动回退到 HTTP/1.1 失败时) enableHttp2: boolean('enable_http2').notNull().default(false), + // 启用 Responses WebSocket 传输(默认关闭,启用后优先使用 WebSocket,失败时回退到 HTTP) + enableResponsesWebSocket: boolean('enable_responses_websocket').notNull().default(false), + // 可选拦截 Anthropic Warmup 请求(默认关闭) // 开启后:对 /v1/messages 的 Warmup 请求直接由 CCH 抢答,避免打到上游供应商 interceptAnthropicWarmupRequests: boolean('intercept_anthropic_warmup_requests') diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 9382f7ee9..46148d4cc 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -27,6 +27,7 @@ let cachedAt: number = 0; const DEFAULT_SETTINGS: Pick< SystemSettings, | "enableHttp2" + | "enableResponsesWebSocket" | "interceptAnthropicWarmupRequests" | "enableThinkingSignatureRectifier" | "enableThinkingBudgetRectifier" @@ -37,6 +38,7 @@ const DEFAULT_SETTINGS: Pick< | "responseFixerConfig" > = { enableHttp2: false, + enableResponsesWebSocket: false, interceptAnthropicWarmupRequests: false, enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, @@ -110,6 +112,7 @@ export async function getCachedSystemSettings(): Promise { cleanupBatchSize: 10000, enableClientVersionCheck: false, enableHttp2: DEFAULT_SETTINGS.enableHttp2, + enableResponsesWebSocket: DEFAULT_SETTINGS.enableResponsesWebSocket, interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: DEFAULT_SETTINGS.enableThinkingBudgetRectifier, @@ -140,6 +143,16 @@ export async function isHttp2Enabled(): Promise { return settings.enableHttp2; } +/** + * Get only the Responses WebSocket enabled setting (optimized for proxy path) + * + * @returns Whether Responses WebSocket is enabled + */ +export async function isResponsesWebSocketEnabled(): Promise { + const settings = await getCachedSystemSettings(); + return settings.enableResponsesWebSocket; +} + /** * Invalidate the settings cache * diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index f00b6d777..87646d2f8 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -934,6 +934,8 @@ export const UpdateSystemSettingsSchema = z.object({ verboseProviderError: z.boolean().optional(), // 启用 HTTP/2 连接供应商(可选) enableHttp2: z.boolean().optional(), + // 启用 Responses WebSocket 传输(可选) + enableResponsesWebSocket: z.boolean().optional(), // 可选拦截 Anthropic Warmup 请求(可选) interceptAnthropicWarmupRequests: z.boolean().optional(), // thinking signature 整流器(可选) diff --git a/src/lib/ws/frame-parser.ts b/src/lib/ws/frame-parser.ts new file mode 100644 index 000000000..18f65ffba --- /dev/null +++ b/src/lib/ws/frame-parser.ts @@ -0,0 +1,85 @@ +import type { ClientFrame, ServerErrorFrame, TerminalEvent } from "./frames"; +import { + ClientFrameSchema, + ServerErrorFrameSchema, + TERMINAL_EVENT_TYPES, + TerminalEventSchema, +} from "./frames"; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +export type ParseResult = { ok: true; data: T } | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Client frame parsing +// --------------------------------------------------------------------------- + +/** + * Parse and validate an incoming client WebSocket message. + * Accepts a raw string or Buffer and returns a structured error on invalid + * JSON or schema violation. + */ +export function parseClientFrame(raw: string | Buffer): ParseResult { + let json: unknown; + try { + const text = typeof raw === "string" ? raw : raw.toString("utf-8"); + json = JSON.parse(text); + } catch { + return { ok: false, error: "Invalid JSON" }; + } + + const result = ClientFrameSchema.safeParse(json); + if (result.success) { + return { ok: true, data: result.data }; + } + + const firstIssue = result.error.issues[0]; + const message = firstIssue + ? `${firstIssue.path.join(".")}: ${firstIssue.message}` + : "Schema validation failed"; + return { ok: false, error: message }; +} + +// --------------------------------------------------------------------------- +// Server event helpers +// --------------------------------------------------------------------------- + +/** + * Check whether a server event type string is terminal + * (response.completed / response.failed / response.incomplete). + */ +export function isTerminalEvent(eventType: string): boolean { + return (TERMINAL_EVENT_TYPES as readonly string[]).includes(eventType); +} + +/** + * Parse a server event payload as a terminal event if it matches the schema. + */ +export function parseTerminalEvent(data: unknown): ParseResult { + const result = TerminalEventSchema.safeParse(data); + if (result.success) { + return { ok: true, data: result.data }; + } + const firstIssue = result.error.issues[0]; + const message = firstIssue + ? `${firstIssue.path.join(".")}: ${firstIssue.message}` + : "Terminal event validation failed"; + return { ok: false, error: message }; +} + +/** + * Parse a server error frame. + */ +export function parseServerError(data: unknown): ParseResult { + const result = ServerErrorFrameSchema.safeParse(data); + if (result.success) { + return { ok: true, data: result.data }; + } + const firstIssue = result.error.issues[0]; + const message = firstIssue + ? `${firstIssue.path.join(".")}: ${firstIssue.message}` + : "Server error validation failed"; + return { ok: false, error: message }; +} diff --git a/src/lib/ws/frames.ts b/src/lib/ws/frames.ts new file mode 100644 index 000000000..50137d111 --- /dev/null +++ b/src/lib/ws/frames.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Reasoning config (mirrors existing ResponseRequest.reasoning) +// Uses .passthrough() to preserve unknown fields (e.g. encrypted_content) +// for forward compatibility. +// --------------------------------------------------------------------------- + +export const ReasoningConfigSchema = z + .object({ + effort: z.enum(["minimal", "low", "medium", "high"]).optional(), + summary: z.enum(["auto", "concise", "detailed"]).optional(), + encrypted_content: z.string().optional(), + }) + .passthrough(); + +// --------------------------------------------------------------------------- +// Service tier: known values + arbitrary string for forward compat +// --------------------------------------------------------------------------- + +export const ServiceTierSchema = z.enum(["auto", "default", "flex", "priority"]).or(z.string()); + +// --------------------------------------------------------------------------- +// Input item - permissive shape matching ResponseRequest.input entries +// --------------------------------------------------------------------------- + +const InputItemSchema = z + .object({ + type: z.string(), + role: z.string().optional(), + content: z.union([z.string(), z.array(z.any())]).optional(), + }) + .passthrough(); + +// ===== Client -> Server Frames ============================================== + +/** + * response.create: the primary client frame. + * The `response` body mirrors ResponseRequest from codex/types/response.ts. + */ +export const ResponseCreateFrameSchema = z.object({ + type: z.literal("response.create"), + response: z + .object({ + model: z.string().min(1), + input: z.array(InputItemSchema).optional(), + instructions: z.string().optional(), + max_output_tokens: z.number().int().positive().optional(), + metadata: z.record(z.string(), z.string()).optional(), + parallel_tool_calls: z.boolean().optional(), + previous_response_id: z.string().optional(), + reasoning: ReasoningConfigSchema.optional(), + store: z.boolean().optional(), + temperature: z.number().optional(), + tool_choice: z.union([z.string(), z.object({}).passthrough()]).optional(), + tools: z.array(z.any()).optional(), + top_p: z.number().optional(), + truncation: z.enum(["auto", "disabled"]).optional(), + user: z.string().optional(), + service_tier: ServiceTierSchema.optional(), + stream: z.boolean().optional(), + prompt_cache_key: z.string().optional(), + }) + .passthrough(), +}); + +/** + * response.cancel: sent by the client to abort an in-progress response. + */ +export const ResponseCancelFrameSchema = z.object({ + type: z.literal("response.cancel"), +}); + +/** + * Union of all valid client frames, discriminated on `type`. + */ +export const ClientFrameSchema = z.discriminatedUnion("type", [ + ResponseCreateFrameSchema, + ResponseCancelFrameSchema, +]); + +// ===== Server -> Client Events =============================================== + +/** Terminal event type literals */ +export const TERMINAL_EVENT_TYPES = [ + "response.completed", + "response.failed", + "response.incomplete", +] as const; + +export type TerminalEventType = (typeof TERMINAL_EVENT_TYPES)[number]; + +/** Usage block present in terminal event responses */ +export const UsageSchema = z + .object({ + input_tokens: z.number().int().nonnegative(), + output_tokens: z.number().int().nonnegative(), + total_tokens: z.number().int().nonnegative().optional(), + output_tokens_details: z + .object({ + reasoning_tokens: z.number().int().nonnegative().optional(), + }) + .optional(), + }) + .passthrough(); + +/** Response object embedded in terminal events */ +export const TerminalResponseSchema = z + .object({ + id: z.string(), + object: z.literal("response").optional(), + model: z.string().optional(), + status: z.enum(["completed", "failed", "incomplete"]), + usage: UsageSchema.optional(), + service_tier: z.string().optional(), + prompt_cache_key: z.string().optional(), + output: z.array(z.any()).optional(), + }) + .passthrough(); + +/** Terminal event frame (response.completed / failed / incomplete) */ +export const TerminalEventSchema = z.object({ + type: z.enum(TERMINAL_EVENT_TYPES), + response: TerminalResponseSchema, +}); + +/** Error frame pushed by the server */ +export const ServerErrorFrameSchema = z.object({ + type: z.literal("error"), + error: z + .object({ + type: z.string(), + code: z.string().optional(), + message: z.string(), + param: z.string().nullable().optional(), + event_id: z.string().optional(), + }) + .passthrough(), +}); + +// ===== Type exports ========================================================== + +export type ResponseCreateFrame = z.infer; +export type ClientFrame = z.infer; +export type TerminalEvent = z.infer; +export type ServerErrorFrame = z.infer; +export type ResponseUsage = z.infer; diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 0bc58987f..3972821e3 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -197,6 +197,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { enableClientVersionCheck: dbSettings?.enableClientVersionCheck ?? false, verboseProviderError: dbSettings?.verboseProviderError ?? false, enableHttp2: dbSettings?.enableHttp2 ?? false, + enableResponsesWebSocket: dbSettings?.enableResponsesWebSocket ?? false, interceptAnthropicWarmupRequests: dbSettings?.interceptAnthropicWarmupRequests ?? false, enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true, enableThinkingBudgetRectifier: dbSettings?.enableThinkingBudgetRectifier ?? true, diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 67e063492..52ef8d5d6 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -148,6 +148,7 @@ function createFallbackSettings(): SystemSettings { enableClientVersionCheck: false, verboseProviderError: false, enableHttp2: false, + enableResponsesWebSocket: false, interceptAnthropicWarmupRequests: false, enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, @@ -192,6 +193,7 @@ export async function getSystemSettings(): Promise { enableClientVersionCheck: systemSettings.enableClientVersionCheck, verboseProviderError: systemSettings.verboseProviderError, enableHttp2: systemSettings.enableHttp2, + enableResponsesWebSocket: systemSettings.enableResponsesWebSocket, interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, @@ -336,6 +338,11 @@ export async function updateSystemSettings( updates.enableHttp2 = payload.enableHttp2; } + // Responses WebSocket 配置字段(如果提供) + if (payload.enableResponsesWebSocket !== undefined) { + updates.enableResponsesWebSocket = payload.enableResponsesWebSocket; + } + // Warmup 拦截开关(如果提供) if (payload.interceptAnthropicWarmupRequests !== undefined) { updates.interceptAnthropicWarmupRequests = payload.interceptAnthropicWarmupRequests; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 000000000..9e95f32c4 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,60 @@ +import { createServer } from "node:http"; +import { parse } from "node:url"; +import next from "next"; +import { WsManager } from "./ws-manager"; + +const dev = process.env.NODE_ENV !== "production"; +const hostname = process.env.HOSTNAME || "0.0.0.0"; +const port = parseInt(process.env.PORT || "3000", 10); + +async function main() { + const app = next({ dev, hostname, port }); + const handle = app.getRequestHandler(); + + await app.prepare(); + + const server = createServer((req, res) => { + const parsedUrl = parse(req.url || "/", true); + handle(req, res, parsedUrl); + }); + + // Attach WebSocket manager on the same HTTP server + const wsManager = new WsManager(server); + + // Placeholder connection handler (will be replaced by ingress-handler in T6) + wsManager.onConnection((ws, req) => { + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + console.log(`[WS] New connection on ${url.pathname}`); + + ws.on("message", () => { + ws.send( + JSON.stringify({ + type: "error", + error: { + type: "server_error", + message: "WebSocket ingress not yet initialized", + }, + }) + ); + }); + }); + + // Graceful shutdown + const shutdown = async () => { + console.log("[Server] Shutting down..."); + await wsManager.close(); + server.close(); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + server.listen(port, hostname, () => { + console.log(`[Server] Ready on http://${hostname}:${port} (HTTP + WS)`); + }); +} + +main().catch((err) => { + console.error("[Server] Failed to start:", err); + process.exit(1); +}); diff --git a/src/server/ws-manager.ts b/src/server/ws-manager.ts new file mode 100644 index 000000000..0522d1ae2 --- /dev/null +++ b/src/server/ws-manager.ts @@ -0,0 +1,85 @@ +import type { Server as HttpServer, IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { type WebSocket, WebSocketServer } from "ws"; + +const WS_PATH = "/v1/responses"; + +export interface WsManagerOptions { + /** Max payload size in bytes (default: 16MB) */ + maxPayloadLength?: number; + /** Heartbeat interval in ms (default: 30000) */ + heartbeatIntervalMs?: number; +} + +export class WsManager { + private wss: WebSocketServer; + private heartbeatInterval: ReturnType | null = null; + + constructor(server: HttpServer, options?: WsManagerOptions) { + const maxPayload = options?.maxPayloadLength ?? 16 * 1024 * 1024; + + this.wss = new WebSocketServer({ + noServer: true, + maxPayload, + }); + + server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => { + const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); + + if (url.pathname === WS_PATH) { + this.wss.handleUpgrade(req, socket, head, (ws) => { + this.wss.emit("connection", ws, req); + }); + } else { + // Not our path; Next.js does not handle WS upgrades + socket.destroy(); + } + }); + + const heartbeatMs = options?.heartbeatIntervalMs ?? 30_000; + this.startHeartbeat(heartbeatMs); + } + + private startHeartbeat(intervalMs: number): void { + this.heartbeatInterval = setInterval(() => { + for (const ws of this.wss.clients) { + if ((ws as any).__isAlive === false) { + ws.terminate(); + continue; + } + (ws as any).__isAlive = false; + ws.ping(); + } + }, intervalMs); + } + + /** Register a connection handler */ + onConnection(handler: (ws: WebSocket, req: IncomingMessage) => void): void { + this.wss.on("connection", (ws, req) => { + (ws as any).__isAlive = true; + ws.on("pong", () => { + (ws as any).__isAlive = true; + }); + handler(ws, req); + }); + } + + /** Get active connection count */ + get connectionCount(): number { + return this.wss.clients.size; + } + + /** Graceful shutdown */ + close(): Promise { + return new Promise((resolve) => { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + for (const ws of this.wss.clients) { + ws.close(1001, "Server shutting down"); + } + this.wss.close(() => resolve()); + }); + } +} diff --git a/src/types/message.ts b/src/types/message.ts index 21a6552a7..25b7ce027 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -33,6 +33,7 @@ export interface ProviderChainItem { | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) + | "ws_fallback" // WebSocket 传输错误,回退到 HTTP(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级) | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 | "client_restriction_filtered"; // Provider skipped due to client restriction (neutral, no circuit breaker) diff --git a/src/types/system-config.ts b/src/types/system-config.ts index d45e4cf20..bb8b677a6 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -42,6 +42,9 @@ export interface SystemSettings { // 启用 HTTP/2 连接供应商 enableHttp2: boolean; + // 启用 Responses WebSocket 传输 + enableResponsesWebSocket: boolean; + // 可选拦截 Anthropic Warmup 请求(默认关闭) interceptAnthropicWarmupRequests: boolean; @@ -111,6 +114,9 @@ export interface UpdateSystemSettingsInput { // 启用 HTTP/2 连接供应商(可选) enableHttp2?: boolean; + // 启用 Responses WebSocket 传输(可选) + enableResponsesWebSocket?: boolean; + // 可选拦截 Anthropic Warmup 请求(可选) interceptAnthropicWarmupRequests?: boolean; diff --git a/tests/unit/lib/config/system-settings-responses-websocket-toggle.test.ts b/tests/unit/lib/config/system-settings-responses-websocket-toggle.test.ts new file mode 100644 index 000000000..94b51250e --- /dev/null +++ b/tests/unit/lib/config/system-settings-responses-websocket-toggle.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { SystemSettings } from "@/types/system-config"; + +// Mock dependencies before import +const getSystemSettingsMock = vi.fn(); +const loggerDebugMock = vi.fn(); +const loggerWarnMock = vi.fn(); +const loggerInfoMock = vi.fn(); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: (...args: unknown[]) => getSystemSettingsMock(...args), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: (...args: unknown[]) => loggerDebugMock(...args), + warn: (...args: unknown[]) => loggerWarnMock(...args), + info: (...args: unknown[]) => loggerInfoMock(...args), + }, +})); + +function createSettings(overrides: Partial = {}): SystemSettings { + const base: SystemSettings = { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + enableResponsesWebSocket: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + enableBillingHeaderRectifier: true, + enableCodexSessionIdCompletion: true, + enableClaudeMetadataUserIdInjection: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + return { ...base, ...overrides }; +} + +async function loadCache() { + const mod = await import("@/lib/config/system-settings-cache"); + return { + getCachedSystemSettings: mod.getCachedSystemSettings, + isHttp2Enabled: mod.isHttp2Enabled, + isResponsesWebSocketEnabled: mod.isResponsesWebSocketEnabled, + invalidateSystemSettingsCache: mod.invalidateSystemSettingsCache, + }; +} + +describe("enableResponsesWebSocket toggle", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-03T00:00:00.000Z")); + vi.resetModules(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("DEFAULT_SETTINGS includes enableResponsesWebSocket: false", async () => { + // When DB fails and no cache exists, the fallback should include enableResponsesWebSocket: false + getSystemSettingsMock.mockRejectedValueOnce(new Error("db down")); + const { getCachedSystemSettings } = await loadCache(); + + const settings = await getCachedSystemSettings(); + expect(settings.enableResponsesWebSocket).toBe(false); + }); + + test("isResponsesWebSocketEnabled() returns the cached value when enabled", async () => { + getSystemSettingsMock.mockResolvedValueOnce( + createSettings({ id: 100, enableResponsesWebSocket: true }) + ); + const { isResponsesWebSocketEnabled } = await loadCache(); + + expect(await isResponsesWebSocketEnabled()).toBe(true); + }); + + test("isResponsesWebSocketEnabled() returns false when disabled", async () => { + getSystemSettingsMock.mockResolvedValueOnce( + createSettings({ id: 101, enableResponsesWebSocket: false }) + ); + const { isResponsesWebSocketEnabled } = await loadCache(); + + expect(await isResponsesWebSocketEnabled()).toBe(false); + }); + + test("transformer defaults to false when DB value is null/undefined", async () => { + // Import transformer directly + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + // null/undefined dbSettings + const fromUndefined = toSystemSettings(undefined); + expect(fromUndefined.enableResponsesWebSocket).toBe(false); + + // DB row with enableResponsesWebSocket missing (null) + const fromNull = toSystemSettings({ id: 1, enableResponsesWebSocket: null }); + expect(fromNull.enableResponsesWebSocket).toBe(false); + + // DB row with explicit false + const fromFalse = toSystemSettings({ id: 2, enableResponsesWebSocket: false }); + expect(fromFalse.enableResponsesWebSocket).toBe(false); + + // DB row with explicit true + const fromTrue = toSystemSettings({ id: 3, enableResponsesWebSocket: true }); + expect(fromTrue.enableResponsesWebSocket).toBe(true); + }); +}); diff --git a/tests/unit/lib/ws/frame-parser.test.ts b/tests/unit/lib/ws/frame-parser.test.ts new file mode 100644 index 000000000..f06e6c678 --- /dev/null +++ b/tests/unit/lib/ws/frame-parser.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "vitest"; +import { + isTerminalEvent, + parseClientFrame, + parseServerError, + parseTerminalEvent, +} from "@/lib/ws/frame-parser"; + +// --------------------------------------------------------------------------- +// parseClientFrame +// --------------------------------------------------------------------------- + +describe("parseClientFrame", () => { + test("returns ok:true for valid JSON frame", () => { + const raw = JSON.stringify({ + type: "response.create", + response: { model: "gpt-4o" }, + }); + + const result = parseClientFrame(raw); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.type).toBe("response.create"); + } + }); + + test("returns ok:false with descriptive error for invalid JSON", () => { + const result = parseClientFrame("{not valid json"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Invalid JSON"); + } + }); + + test("returns ok:false for valid JSON that fails schema", () => { + const raw = JSON.stringify({ type: "response.create", response: {} }); + const result = parseClientFrame(raw); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.length).toBeGreaterThan(0); + } + }); + + test("handles binary Buffer input", () => { + const payload = JSON.stringify({ + type: "response.create", + response: { model: "gpt-4o" }, + }); + const buf = Buffer.from(payload, "utf-8"); + + const result = parseClientFrame(buf); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.type).toBe("response.create"); + } + }); + + test("accepts response.cancel frame", () => { + const raw = JSON.stringify({ type: "response.cancel" }); + const result = parseClientFrame(raw); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.type).toBe("response.cancel"); + } + }); +}); + +// --------------------------------------------------------------------------- +// isTerminalEvent +// --------------------------------------------------------------------------- + +describe("isTerminalEvent", () => { + test("returns true for response.completed", () => { + expect(isTerminalEvent("response.completed")).toBe(true); + }); + + test("returns true for response.failed", () => { + expect(isTerminalEvent("response.failed")).toBe(true); + }); + + test("returns true for response.incomplete", () => { + expect(isTerminalEvent("response.incomplete")).toBe(true); + }); + + test("returns false for non-terminal events", () => { + expect(isTerminalEvent("response.created")).toBe(false); + expect(isTerminalEvent("response.output_text.delta")).toBe(false); + expect(isTerminalEvent("error")).toBe(false); + expect(isTerminalEvent("")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// parseTerminalEvent +// --------------------------------------------------------------------------- + +describe("parseTerminalEvent", () => { + test("extracts usage from response.completed", () => { + const data = { + type: "response.completed", + response: { + id: "resp_123", + status: "completed", + model: "gpt-4o", + usage: { + input_tokens: 200, + output_tokens: 100, + total_tokens: 300, + output_tokens_details: { reasoning_tokens: 50 }, + }, + }, + }; + + const result = parseTerminalEvent(data); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.response.usage?.input_tokens).toBe(200); + expect(result.data.response.usage?.output_tokens).toBe(100); + expect(result.data.response.usage?.output_tokens_details?.reasoning_tokens).toBe(50); + } + }); + + test("extracts status from response.failed", () => { + const data = { + type: "response.failed", + response: { id: "resp_456", status: "failed" }, + }; + + const result = parseTerminalEvent(data); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.response.status).toBe("failed"); + } + }); + + test("returns ok:false for invalid terminal event", () => { + const result = parseTerminalEvent({ type: "response.completed" }); + expect(result.ok).toBe(false); + }); + + test("preserves prompt_cache_key in terminal response", () => { + const data = { + type: "response.completed", + response: { + id: "resp_789", + status: "completed", + prompt_cache_key: "cache_key_abc", + }, + }; + + const result = parseTerminalEvent(data); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.response.prompt_cache_key).toBe("cache_key_abc"); + } + }); +}); + +// --------------------------------------------------------------------------- +// parseServerError +// --------------------------------------------------------------------------- + +describe("parseServerError", () => { + test("extracts error details", () => { + const data = { + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_model", + message: "Model not found", + param: "model", + event_id: "evt_abc", + }, + }; + + const result = parseServerError(data); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.error.type).toBe("invalid_request_error"); + expect(result.data.error.code).toBe("invalid_model"); + expect(result.data.error.message).toBe("Model not found"); + expect(result.data.error.param).toBe("model"); + expect(result.data.error.event_id).toBe("evt_abc"); + } + }); + + test("accepts minimal error with only type and message", () => { + const data = { + type: "error", + error: { type: "server_error", message: "Something went wrong" }, + }; + + const result = parseServerError(data); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.error.message).toBe("Something went wrong"); + } + }); + + test("returns ok:false for missing error object", () => { + const result = parseServerError({ type: "error" }); + expect(result.ok).toBe(false); + }); +}); diff --git a/tests/unit/lib/ws/frames.test.ts b/tests/unit/lib/ws/frames.test.ts new file mode 100644 index 000000000..791961791 --- /dev/null +++ b/tests/unit/lib/ws/frames.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, test } from "vitest"; +import { + ClientFrameSchema, + ReasoningConfigSchema, + ResponseCreateFrameSchema, + ServiceTierSchema, + TerminalEventSchema, + UsageSchema, + ServerErrorFrameSchema, +} from "@/lib/ws/frames"; + +// --------------------------------------------------------------------------- +// ResponseCreateFrameSchema +// --------------------------------------------------------------------------- + +describe("ResponseCreateFrameSchema", () => { + test("accepts valid response.create with all optional fields", () => { + const frame = { + type: "response.create", + response: { + model: "gpt-4o", + input: [{ type: "message", role: "user", content: "hello" }], + instructions: "be concise", + max_output_tokens: 4096, + metadata: { session_id: "sess_abc" }, + parallel_tool_calls: true, + previous_response_id: "resp_prev_123", + reasoning: { effort: "high", summary: "auto" }, + store: true, + temperature: 0.7, + tool_choice: "auto", + tools: [{ type: "function", function: { name: "search" } }], + top_p: 0.9, + truncation: "auto", + user: "user_123", + service_tier: "flex", + stream: true, + prompt_cache_key: "019b82ff-08ff-75a3-a203-7e10274fdbd8", + }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + }); + + test("accepts minimal response.create with only model", () => { + const frame = { + type: "response.create", + response: { model: "gpt-4o" }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + }); + + test("accepts service_tier:'flex'", () => { + const frame = { + type: "response.create", + response: { model: "gpt-4o", service_tier: "flex" }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.response.service_tier).toBe("flex"); + } + }); + + test("accepts stream:false (non-streaming)", () => { + const frame = { + type: "response.create", + response: { model: "gpt-4o", stream: false }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.response.stream).toBe(false); + } + }); + + test("preserves reasoning.encrypted_content bytes", () => { + const encrypted = "base64+encrypted/content=="; + const frame = { + type: "response.create", + response: { + model: "gpt-4o", + reasoning: { effort: "high", encrypted_content: encrypted }, + }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.response.reasoning?.encrypted_content).toBe(encrypted); + } + }); + + test("accepts previous_response_id", () => { + const frame = { + type: "response.create", + response: { + model: "gpt-4o", + previous_response_id: "resp_abc123456789", + }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.response.previous_response_id).toBe("resp_abc123456789"); + } + }); + + test("rejects missing model", () => { + const frame = { + type: "response.create", + response: {}, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(false); + }); + + test("rejects empty model string", () => { + const frame = { + type: "response.create", + response: { model: "" }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(false); + }); + + test("preserves unknown fields via passthrough on response body", () => { + const frame = { + type: "response.create", + response: { + model: "gpt-4o", + new_future_field: "some-value", + }, + }; + + const result = ResponseCreateFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data.response as Record).new_future_field).toBe("some-value"); + } + }); +}); + +// --------------------------------------------------------------------------- +// ClientFrameSchema (discriminated union) +// --------------------------------------------------------------------------- + +describe("ClientFrameSchema", () => { + test("rejects missing type field", () => { + const result = ClientFrameSchema.safeParse({ response: { model: "gpt-4o" } }); + expect(result.success).toBe(false); + }); + + test("rejects invalid type field", () => { + const result = ClientFrameSchema.safeParse({ + type: "response.unknown", + response: { model: "gpt-4o" }, + }); + expect(result.success).toBe(false); + }); + + test("accepts response.cancel", () => { + const result = ClientFrameSchema.safeParse({ type: "response.cancel" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe("response.cancel"); + } + }); +}); + +// --------------------------------------------------------------------------- +// ServiceTierSchema forward compatibility +// --------------------------------------------------------------------------- + +describe("ServiceTierSchema", () => { + test("accepts known tier values", () => { + for (const tier of ["auto", "default", "flex", "priority"]) { + expect(ServiceTierSchema.safeParse(tier).success).toBe(true); + } + }); + + test("accepts unknown string tier for forward compat", () => { + const result = ServiceTierSchema.safeParse("new-future-tier"); + expect(result.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// ReasoningConfigSchema +// --------------------------------------------------------------------------- + +describe("ReasoningConfigSchema", () => { + test("preserves unknown fields via passthrough", () => { + const config = { effort: "high", future_flag: true }; + const result = ReasoningConfigSchema.safeParse(config); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as Record).future_flag).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// UsageSchema +// --------------------------------------------------------------------------- + +describe("UsageSchema", () => { + test("accepts complete usage block", () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + output_tokens_details: { reasoning_tokens: 20 }, + }; + const result = UsageSchema.safeParse(usage); + expect(result.success).toBe(true); + }); + + test("accepts usage without optional fields", () => { + const usage = { input_tokens: 100, output_tokens: 50 }; + const result = UsageSchema.safeParse(usage); + expect(result.success).toBe(true); + }); + + test("rejects negative token counts", () => { + const usage = { input_tokens: -1, output_tokens: 50 }; + const result = UsageSchema.safeParse(usage); + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// TerminalEventSchema +// --------------------------------------------------------------------------- + +describe("TerminalEventSchema", () => { + test("accepts response.completed with usage", () => { + const event = { + type: "response.completed", + response: { + id: "resp_abc123", + status: "completed", + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }; + const result = TerminalEventSchema.safeParse(event); + expect(result.success).toBe(true); + }); + + test("accepts response.failed", () => { + const event = { + type: "response.failed", + response: { id: "resp_abc123", status: "failed" }, + }; + const result = TerminalEventSchema.safeParse(event); + expect(result.success).toBe(true); + }); + + test("accepts response.incomplete", () => { + const event = { + type: "response.incomplete", + response: { id: "resp_abc123", status: "incomplete" }, + }; + const result = TerminalEventSchema.safeParse(event); + expect(result.success).toBe(true); + }); + + test("rejects non-terminal event type", () => { + const event = { + type: "response.output_text.delta", + response: { id: "resp_abc123", status: "completed" }, + }; + const result = TerminalEventSchema.safeParse(event); + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// ServerErrorFrameSchema +// --------------------------------------------------------------------------- + +describe("ServerErrorFrameSchema", () => { + test("accepts full error frame", () => { + const frame = { + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_model", + message: "The model does not exist", + param: "model", + event_id: "evt_123", + }, + }; + const result = ServerErrorFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + }); + + test("accepts minimal error frame", () => { + const frame = { + type: "error", + error: { type: "server_error", message: "Internal error" }, + }; + const result = ServerErrorFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + }); + + test("accepts null param", () => { + const frame = { + type: "error", + error: { type: "server_error", message: "err", param: null }, + }; + const result = ServerErrorFrameSchema.safeParse(frame); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/unit/proxy/transport-classifier.test.ts b/tests/unit/proxy/transport-classifier.test.ts new file mode 100644 index 000000000..b6dc3510d --- /dev/null +++ b/tests/unit/proxy/transport-classifier.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock "server-only" to avoid import errors in test environment +vi.mock("server-only", () => ({})); + +// Use vi.hoisted so the mock fn is available inside vi.mock factory +const { isResponsesWebSocketEnabledMock } = vi.hoisted(() => ({ + isResponsesWebSocketEnabledMock: vi.fn<() => Promise>(), +})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + isResponsesWebSocketEnabled: (...args: unknown[]) => isResponsesWebSocketEnabledMock(...args), +})); + +import { classifyTransport, toWebSocketUrl } from "@/app/v1/_lib/proxy/transport-classifier"; +import type { Provider } from "@/types/provider"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMinimalSession(pathname: string): ProxySession { + return { + requestUrl: new URL(`https://hub.example.com${pathname}`), + } as unknown as ProxySession; +} + +function createMinimalProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "test-provider", + url: "https://api.openai.com", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "codex", + preserveClientIp: false, + modelRedirects: null, + activeTimeStart: null, + activeTimeEnd: null, + allowedModels: null, + allowedClients: [], + blockedClients: [], + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 60000, + streamingIdleTimeoutMs: 30000, + requestTimeoutNonStreamingMs: 120000, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as unknown as Provider; +} + +// --------------------------------------------------------------------------- +// classifyTransport +// --------------------------------------------------------------------------- + +describe("classifyTransport", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns http when global toggle is disabled", async () => { + isResponsesWebSocketEnabledMock.mockResolvedValue(false); + + const session = createMinimalSession("/v1/responses"); + const provider = createMinimalProvider(); + + const result = await classifyTransport(session, provider); + + expect(result).toEqual({ + transport: "http", + reason: "websocket_disabled", + }); + }); + + it("returns http for non-/v1/responses endpoints", async () => { + isResponsesWebSocketEnabledMock.mockResolvedValue(true); + + const endpoints = [ + "/v1/messages", + "/v1/chat/completions", + "/v1/response", + "/v1/responses/list", + ]; + + for (const ep of endpoints) { + const session = createMinimalSession(ep); + const provider = createMinimalProvider(); + + const result = await classifyTransport(session, provider); + + expect(result.transport).toBe("http"); + expect(result.reason).toBe("not_responses_endpoint"); + } + }); + + const nonCodexTypes = [ + "claude", + "claude-auth", + "gemini", + "gemini-cli", + "openai-compatible", + ] as const; + + it.each(nonCodexTypes)("returns http for non-codex provider type: %s", async (providerType) => { + isResponsesWebSocketEnabledMock.mockResolvedValue(true); + + const session = createMinimalSession("/v1/responses"); + const provider = createMinimalProvider({ providerType }); + + const result = await classifyTransport(session, provider); + + expect(result).toEqual({ + transport: "http", + reason: "provider_type_not_codex", + }); + }); + + it("returns http when provider URL is not HTTPS", async () => { + isResponsesWebSocketEnabledMock.mockResolvedValue(true); + + const session = createMinimalSession("/v1/responses"); + const provider = createMinimalProvider({ url: "http://api.openai.com" }); + + const result = await classifyTransport(session, provider); + + expect(result).toEqual({ + transport: "http", + reason: "provider_url_not_https", + }); + }); + + it("returns http when provider URL is empty", async () => { + isResponsesWebSocketEnabledMock.mockResolvedValue(true); + + const session = createMinimalSession("/v1/responses"); + const provider = createMinimalProvider({ url: "" }); + + const result = await classifyTransport(session, provider); + + expect(result).toEqual({ + transport: "http", + reason: "provider_url_not_https", + }); + }); + + it("returns http when proxy is configured", async () => { + isResponsesWebSocketEnabledMock.mockResolvedValue(true); + + const session = createMinimalSession("/v1/responses"); + const provider = createMinimalProvider({ + proxyUrl: "http://proxy.internal:8080", + }); + + const result = await classifyTransport(session, provider); + + expect(result).toEqual({ + transport: "http", + reason: "proxy_configured", + }); + }); + + it("returns websocket when ALL conditions are met", async () => { + isResponsesWebSocketEnabledMock.mockResolvedValue(true); + + const session = createMinimalSession("/v1/responses"); + const provider = createMinimalProvider({ + providerType: "codex", + url: "https://api.openai.com", + proxyUrl: null, + }); + + const result = await classifyTransport(session, provider); + + expect(result).toEqual({ + transport: "websocket", + reason: "all_conditions_met", + }); + }); + + it("checks conditions in priority order (toggle first)", async () => { + // Toggle off should short-circuit before checking other conditions + isResponsesWebSocketEnabledMock.mockResolvedValue(false); + + const session = createMinimalSession("/v1/messages"); + const provider = createMinimalProvider({ providerType: "claude" }); + + const result = await classifyTransport(session, provider); + + // Should return websocket_disabled, not any other reason + expect(result.reason).toBe("websocket_disabled"); + }); +}); + +// --------------------------------------------------------------------------- +// toWebSocketUrl +// --------------------------------------------------------------------------- + +describe("toWebSocketUrl", () => { + it("converts https:// to wss:// correctly", () => { + const result = toWebSocketUrl("https://api.openai.com"); + expect(result).toBe("wss://api.openai.com/v1/responses"); + }); + + it("appends /v1/responses if not present", () => { + const result = toWebSocketUrl("https://api.openai.com/some/path"); + expect(result).toBe("wss://api.openai.com/some/path/v1/responses"); + }); + + it("preserves existing /v1/responses path", () => { + const result = toWebSocketUrl("https://api.openai.com/v1/responses"); + expect(result).toBe("wss://api.openai.com/v1/responses"); + }); + + it("handles trailing slash in base URL", () => { + const result = toWebSocketUrl("https://api.openai.com/"); + expect(result).toBe("wss://api.openai.com/v1/responses"); + }); + + it("preserves port number", () => { + const result = toWebSocketUrl("https://localhost:8443"); + expect(result).toBe("wss://localhost:8443/v1/responses"); + }); + + it("handles URL with existing path segments", () => { + const result = toWebSocketUrl("https://proxy.example.com/api/v2"); + expect(result).toBe("wss://proxy.example.com/api/v2/v1/responses"); + }); +}); diff --git a/tests/unit/server/ws-manager.test.ts b/tests/unit/server/ws-manager.test.ts new file mode 100644 index 000000000..d5dd92810 --- /dev/null +++ b/tests/unit/server/ws-manager.test.ts @@ -0,0 +1,274 @@ +import type { Server as HttpServer, IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// vi.hoisted() ensures shared state is available when vi.mock factory executes +// --------------------------------------------------------------------------- + +const mockState = vi.hoisted(() => { + const state = { + clients: new Set(), + wss: null as any, + opts: null as any, + }; + return state; +}); + +vi.mock("ws", () => { + const { EventEmitter: EE } = require("node:events"); + + class MockWebSocketServer extends EE { + clients: Set; + handleUpgrade: ReturnType; + close: ReturnType; + + constructor(opts: any) { + super(); + this.clients = mockState.clients; + this.handleUpgrade = vi.fn(); + this.close = vi.fn((cb?: () => void) => cb?.()); + mockState.wss = this; + mockState.opts = opts; + } + } + return { WebSocketServer: MockWebSocketServer }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockServer(): HttpServer { + return new EventEmitter() as unknown as HttpServer; +} + +function createMockSocket(): Duplex { + return { + destroy: vi.fn(), + } as unknown as Duplex; +} + +function createMockWs() { + return Object.assign(new EventEmitter(), { + ping: vi.fn(), + close: vi.fn(), + terminate: vi.fn(), + send: vi.fn(), + }); +} + +function createMockRequest(urlPath: string): IncomingMessage { + return { + url: urlPath, + headers: { host: "localhost:3000" }, + } as unknown as IncomingMessage; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("WsManager", () => { + let server: HttpServer; + + beforeEach(() => { + vi.useFakeTimers(); + server = createMockServer(); + mockState.clients.clear(); + mockState.wss = null; + mockState.opts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("constructor creates WSS in noServer mode", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server); + + expect(mockState.opts).toEqual( + expect.objectContaining({ + noServer: true, + maxPayload: 16 * 1024 * 1024, + }) + ); + }); + + test("constructor respects custom maxPayloadLength", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server, { maxPayloadLength: 1024 }); + + expect(mockState.opts).toEqual( + expect.objectContaining({ + noServer: true, + maxPayload: 1024, + }) + ); + }); + + test("handleUpgrade is called for /v1/responses path", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server); + + const socket = createMockSocket(); + const head = Buffer.alloc(0); + const req = createMockRequest("/v1/responses"); + + mockState.wss.handleUpgrade.mockImplementation( + (_req: any, _socket: any, _head: any, cb: (ws: any) => void) => { + cb(createMockWs()); + } + ); + + server.emit("upgrade", req, socket, head); + + expect(mockState.wss.handleUpgrade).toHaveBeenCalledWith( + req, + socket, + head, + expect.any(Function) + ); + }); + + test("non-matching paths get socket destroyed", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server); + + const socket = createMockSocket(); + const head = Buffer.alloc(0); + const req = createMockRequest("/v1/messages"); + + server.emit("upgrade", req, socket, head); + + expect(mockState.wss.handleUpgrade).not.toHaveBeenCalled(); + expect(socket.destroy).toHaveBeenCalled(); + }); + + test("onConnection handler receives connections", async () => { + const { WsManager } = await import("@/server/ws-manager"); + const manager = new WsManager(server); + + const handler = vi.fn(); + manager.onConnection(handler); + + const ws = createMockWs(); + const req = createMockRequest("/v1/responses"); + + mockState.wss.emit("connection", ws, req); + + expect(handler).toHaveBeenCalledWith(ws, req); + expect((ws as any).__isAlive).toBe(true); + }); + + test("onConnection sets up pong listener to mark client alive", async () => { + const { WsManager } = await import("@/server/ws-manager"); + const manager = new WsManager(server); + + manager.onConnection(vi.fn()); + + const ws = createMockWs(); + mockState.wss.emit("connection", ws, createMockRequest("/v1/responses")); + + // Simulate heartbeat marking as dead + (ws as any).__isAlive = false; + + // Pong should restore alive status + ws.emit("pong"); + expect((ws as any).__isAlive).toBe(true); + }); + + test("connectionCount returns correct number", async () => { + const { WsManager } = await import("@/server/ws-manager"); + const manager = new WsManager(server); + + expect(manager.connectionCount).toBe(0); + + mockState.clients.add(createMockWs()); + expect(manager.connectionCount).toBe(1); + + mockState.clients.add(createMockWs()); + expect(manager.connectionCount).toBe(2); + }); + + test("heartbeat pings alive clients", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server, { heartbeatIntervalMs: 1000 }); + + const ws = createMockWs(); + (ws as any).__isAlive = true; + mockState.clients.add(ws); + + vi.advanceTimersByTime(1000); + + expect(ws.ping).toHaveBeenCalled(); + expect((ws as any).__isAlive).toBe(false); + }); + + test("heartbeat terminates dead clients", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server, { heartbeatIntervalMs: 1000 }); + + const ws = createMockWs(); + (ws as any).__isAlive = false; + mockState.clients.add(ws); + + vi.advanceTimersByTime(1000); + + expect(ws.terminate).toHaveBeenCalled(); + expect(ws.ping).not.toHaveBeenCalled(); + }); + + test("close() terminates all clients and clears heartbeat", async () => { + const { WsManager } = await import("@/server/ws-manager"); + const manager = new WsManager(server, { heartbeatIntervalMs: 1000 }); + + const ws1 = createMockWs(); + const ws2 = createMockWs(); + mockState.clients.add(ws1); + mockState.clients.add(ws2); + + await manager.close(); + + expect(ws1.close).toHaveBeenCalledWith(1001, "Server shutting down"); + expect(ws2.close).toHaveBeenCalledWith(1001, "Server shutting down"); + expect(mockState.wss.close).toHaveBeenCalled(); + + // After close, advancing timers should not trigger heartbeat pings + const ws3 = createMockWs(); + (ws3 as any).__isAlive = true; + mockState.clients.add(ws3); + vi.advanceTimersByTime(2000); + expect(ws3.ping).not.toHaveBeenCalled(); + }); + + test("handleUpgrade emits connection event on WSS", async () => { + const { WsManager } = await import("@/server/ws-manager"); + new WsManager(server); + + const connectionSpy = vi.fn(); + mockState.wss.on("connection", connectionSpy); + + const ws = createMockWs(); + mockState.wss.handleUpgrade.mockImplementation( + (_req: any, _socket: any, _head: any, cb: (ws: any) => void) => { + cb(ws); + } + ); + + const req = createMockRequest("/v1/responses"); + server.emit("upgrade", req, createMockSocket(), Buffer.alloc(0)); + + expect(connectionSpy).toHaveBeenCalledWith(ws, req); + }); + + test("close() resolves even with no active clients", async () => { + const { WsManager } = await import("@/server/ws-manager"); + const manager = new WsManager(server); + + await expect(manager.close()).resolves.toBeUndefined(); + expect(mockState.wss.close).toHaveBeenCalled(); + }); +}); From 2d51ef998bff96ff0c019e4146625ba8b3f9c7ae Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 01:31:50 +0800 Subject: [PATCH 2/4] feat(ws): WebSocket outbound adapter, ingress handler, event bridge, and session continuity Wave 2 of OpenAI Responses WebSocket support: - OutboundWsAdapter: request-scoped upstream WS connection with configurable timeouts, flex tier support, and terminal event collection - WsIngressHandler: delayed bridging state machine (waiting/processing/closed) with auth validation, first-frame timeout, and sequential turn support - WsEventBridge: bounded ring buffer for event relay with terminal settlement (response.completed/failed/incomplete) and usage extraction - Session continuity: turn context tracking, prompt_cache_key session binding, disconnect classification (retryable vs terminal), neutral fallback rules --- src/app/v1/_lib/ws/event-bridge.ts | 186 +++++++ src/app/v1/_lib/ws/ingress-handler.ts | 363 ++++++++++++++ src/app/v1/_lib/ws/outbound-adapter.ts | 250 ++++++++++ src/app/v1/_lib/ws/session-continuity.ts | 221 +++++++++ tests/unit/ws/event-bridge.test.ts | 598 +++++++++++++++++++++++ tests/unit/ws/ingress-handler.test.ts | 597 ++++++++++++++++++++++ tests/unit/ws/outbound-adapter.test.ts | 574 ++++++++++++++++++++++ tests/unit/ws/session-continuity.test.ts | 393 +++++++++++++++ 8 files changed, 3182 insertions(+) create mode 100644 src/app/v1/_lib/ws/event-bridge.ts create mode 100644 src/app/v1/_lib/ws/ingress-handler.ts create mode 100644 src/app/v1/_lib/ws/outbound-adapter.ts create mode 100644 src/app/v1/_lib/ws/session-continuity.ts create mode 100644 tests/unit/ws/event-bridge.test.ts create mode 100644 tests/unit/ws/ingress-handler.test.ts create mode 100644 tests/unit/ws/outbound-adapter.test.ts create mode 100644 tests/unit/ws/session-continuity.test.ts diff --git a/src/app/v1/_lib/ws/event-bridge.ts b/src/app/v1/_lib/ws/event-bridge.ts new file mode 100644 index 000000000..5594cfa93 --- /dev/null +++ b/src/app/v1/_lib/ws/event-bridge.ts @@ -0,0 +1,186 @@ +import type WebSocket from "ws"; +import { logger } from "@/lib/logger"; +import { isTerminalEvent, parseTerminalEvent } from "@/lib/ws/frame-parser"; +import type { ResponseUsage } from "@/lib/ws/frames"; + +/** Maximum events retained in ring buffer for debugging */ +const MAX_RING_BUFFER_SIZE = 100; + +export interface EventBridgeOptions { + /** Max events in ring buffer (default: 100) */ + maxBufferSize?: number; +} + +export type SettlementStatus = "completed" | "failed" | "incomplete" | "error" | "disconnected"; + +export interface SettlementResult { + status: SettlementStatus; + /** Usage from terminal event (only present on completed/failed/incomplete) */ + usage?: ResponseUsage; + /** Model from terminal response */ + model?: string; + /** Service tier from terminal response */ + serviceTier?: string; + /** Prompt cache key from terminal response */ + promptCacheKey?: string; + /** Total events relayed */ + eventCount: number; + /** Duration from first event to terminal in ms */ + durationMs: number; + /** Error message if status is error/disconnected */ + errorMessage?: string; + /** Terminal event type */ + terminalType?: string; +} + +/** + * Bidirectional event bridge between upstream WS and client WS. + * + * Uses a bounded ring buffer - only retains the last N events for + * debugging/logging. Does NOT accumulate all events in memory. + * + * Usage is extracted ONLY from terminal events (response.completed, + * response.failed, response.incomplete), never from intermediate deltas. + */ +export class WsEventBridge { + private ringBuffer: Array<{ type: string; timestamp: number }>; + private bufferIndex = 0; + private eventCount = 0; + private startTime: number | null = null; + private settlement: SettlementResult | null = null; + private maxBufferSize: number; + + constructor(options?: EventBridgeOptions) { + this.maxBufferSize = options?.maxBufferSize ?? MAX_RING_BUFFER_SIZE; + this.ringBuffer = new Array(this.maxBufferSize); + } + + /** + * Relay an upstream server event to the client WebSocket. + * + * - Writes to ring buffer (bounded, overwrites oldest) + * - Forwards raw JSON to client + * - Checks for terminal events and extracts settlement data + * + * Returns true if the event was terminal (bridge should stop after). + */ + relayEvent( + clientWs: WebSocket, + eventData: { type: string; data: unknown }, + rawJson: string + ): boolean { + if (this.startTime === null) { + this.startTime = Date.now(); + } + + this.eventCount++; + + // Write to ring buffer (bounded) + this.ringBuffer[this.bufferIndex % this.maxBufferSize] = { + type: eventData.type, + timestamp: Date.now(), + }; + this.bufferIndex++; + + // Forward to client if socket is open + if (clientWs.readyState === clientWs.OPEN) { + clientWs.send(rawJson); + } + + // Check for terminal event + if (isTerminalEvent(eventData.type)) { + const terminalResult = parseTerminalEvent(eventData.data); + const durationMs = Date.now() - (this.startTime ?? Date.now()); + + if (terminalResult.ok) { + const te = terminalResult.data; + this.settlement = { + status: te.response.status as SettlementStatus, + usage: te.response.usage ?? undefined, + model: te.response.model ?? undefined, + serviceTier: te.response.service_tier ?? undefined, + promptCacheKey: te.response.prompt_cache_key ?? undefined, + eventCount: this.eventCount, + durationMs, + terminalType: eventData.type, + }; + } else { + this.settlement = { + status: "error", + eventCount: this.eventCount, + durationMs, + errorMessage: `Terminal event parse error: ${terminalResult.error}`, + terminalType: eventData.type, + }; + } + + logger.debug("[EventBridge] Terminal event", { + type: eventData.type, + eventCount: this.eventCount, + durationMs, + status: this.settlement.status, + }); + + return true; + } + + return false; + } + + /** + * Record a disconnection or error settlement (no terminal event received). + */ + settleError(errorMessage: string, status: "error" | "disconnected" = "error"): void { + if (this.settlement) return; + this.settlement = { + status, + eventCount: this.eventCount, + durationMs: this.startTime ? Date.now() - this.startTime : 0, + errorMessage, + }; + } + + /** + * Get the settlement result. Only available after terminal event or error. + */ + getSettlement(): SettlementResult | null { + return this.settlement; + } + + /** Whether this bridge has settled (terminal event received or error) */ + get isSettled(): boolean { + return this.settlement !== null; + } + + /** Total events processed */ + get totalEvents(): number { + return this.eventCount; + } + + /** + * Get recent events from ring buffer (for debugging/logging). + * Returns events in chronological order. + */ + getRecentEvents(): Array<{ type: string; timestamp: number }> { + const filled = Math.min(this.bufferIndex, this.maxBufferSize); + const result: Array<{ type: string; timestamp: number }> = []; + const startIdx = + this.bufferIndex > this.maxBufferSize ? this.bufferIndex % this.maxBufferSize : 0; + for (let i = 0; i < filled; i++) { + const idx = (startIdx + i) % this.maxBufferSize; + if (this.ringBuffer[idx]) { + result.push(this.ringBuffer[idx]); + } + } + return result; + } + + /** Reset for a new turn (sequential turn reuse) */ + reset(): void { + this.ringBuffer = new Array(this.maxBufferSize); + this.bufferIndex = 0; + this.eventCount = 0; + this.startTime = null; + this.settlement = null; + } +} diff --git a/src/app/v1/_lib/ws/ingress-handler.ts b/src/app/v1/_lib/ws/ingress-handler.ts new file mode 100644 index 000000000..a7aafe806 --- /dev/null +++ b/src/app/v1/_lib/ws/ingress-handler.ts @@ -0,0 +1,363 @@ +import type { IncomingMessage } from "node:http"; +import type WebSocket from "ws"; + +import { isResponsesWebSocketEnabled } from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; +import { parseClientFrame } from "@/lib/ws/frame-parser"; +import type { ResponseCreateFrame } from "@/lib/ws/frames"; +import { validateApiKeyAndGetUser } from "@/repository/key"; +import type { Key } from "@/types/key"; +import type { User } from "@/types/user"; + +import { extractApiKeyFromHeaders } from "../proxy/auth-guard"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Connection lifecycle state */ +export type ConnectionState = "waiting" | "processing" | "closed"; + +/** Authenticated identity from upgrade-time validation */ +export interface WsAuthContext { + user: User; + key: Key; + apiKey: string; +} + +/** Per-turn metadata extracted from response.create */ +export interface TurnMeta { + model: string; + serviceTier: string | undefined; + previousResponseId: string | undefined; + frame: ResponseCreateFrame; +} + +export interface IngressHandlerOptions { + /** Max non-create frames to buffer before closing (default: 5) */ + maxBufferedFrames?: number; + /** Max time to wait for first response.create in ms (default: 30000) */ + firstFrameTimeoutMs?: number; +} + +// --------------------------------------------------------------------------- +// WsIngressHandler +// --------------------------------------------------------------------------- + +/** + * Handle a single WebSocket connection on /v1/responses. + * + * Lifecycle: + * 1. Connection accepted, auth validated (upgrade-time) + * 2. Wait for first response.create frame (state: waiting) + * 3. Extract model/service_tier/previous_response_id (delayed bridging) + * 4. Bridge to upstream via outbound adapter (state: processing) + * 5. Relay events to client until terminal event + * 6. Return to waiting state for sequential turns + * + * Invariants: + * - Only ONE in-flight response at a time per socket + * - Auth runs at upgrade time, provider selection deferred to first frame + * - Guard pipeline runs AFTER first frame (delayed bridging) + */ +export class WsIngressHandler { + private state: ConnectionState = "waiting"; + private options: Required; + private turnCount = 0; + private currentMeta: TurnMeta | null = null; + private auth: WsAuthContext | null = null; + private ip: string; + + constructor( + private ws: WebSocket, + private req: IncomingMessage, + options?: IngressHandlerOptions + ) { + this.options = { + maxBufferedFrames: options?.maxBufferedFrames ?? 5, + firstFrameTimeoutMs: options?.firstFrameTimeoutMs ?? 30000, + }; + this.ip = extractClientIp(req); + } + + /** + * Initialize the handler: check toggle, authenticate, set up listeners. + * Returns true if the connection is ready to accept frames. + * Returns false if the connection was rejected (socket closed). + */ + async start(): Promise { + // 1. Check global toggle + const wsEnabled = await isResponsesWebSocketEnabled(); + if (!wsEnabled) { + logger.debug("[WsIngress] Responses WebSocket disabled by system toggle"); + this.ws.close(4003, "Responses WebSocket is disabled"); + this.state = "closed"; + return false; + } + + // 2. Authenticate using request headers + const apiKey = extractApiKeyFromHeaders({ + authorization: this.req.headers.authorization ?? null, + "x-api-key": (this.req.headers["x-api-key"] as string) ?? null, + "x-goog-api-key": (this.req.headers["x-goog-api-key"] as string) ?? null, + }); + + if (!apiKey) { + logger.debug("[WsIngress] No auth credentials in upgrade request"); + this.ws.close(4001, "No auth credentials provided"); + this.state = "closed"; + return false; + } + + const authResult = await validateApiKeyAndGetUser(apiKey); + if (!authResult) { + logger.debug("[WsIngress] API key validation failed"); + this.ws.close(4001, "API key invalid or expired"); + this.state = "closed"; + return false; + } + + // Check user enabled + if (!authResult.user.isEnabled) { + logger.debug("[WsIngress] User disabled", { userId: authResult.user.id }); + this.ws.close(4001, "User account disabled"); + this.state = "closed"; + return false; + } + + this.auth = { + user: authResult.user, + key: authResult.key, + apiKey, + }; + + logger.debug("[WsIngress] Authenticated", { + userId: authResult.user.id, + userName: authResult.user.name, + clientIp: this.ip, + }); + + // 3. Set up message/close/error listeners + this.setupListeners(); + return true; + } + + private setupListeners(): void { + let firstFrameTimer: ReturnType | null = null; + let bufferedNonCreateCount = 0; + + // First-frame timeout + firstFrameTimer = setTimeout(() => { + if (this.state === "waiting") { + this.sendError("timeout", "No response.create received within timeout"); + this.ws.close(1000); + this.state = "closed"; + } + }, this.options.firstFrameTimeoutMs); + + // Message handler is intentionally NOT async. + // handleTurn is dispatched via .catch()/.finally() so that + // state transitions for concurrent rejection are synchronous. + this.ws.on("message", (data: Buffer | string) => { + if (this.state === "closed") return; + + const raw = typeof data === "string" ? data : data.toString("utf-8"); + const parseResult = parseClientFrame(raw); + + if (!parseResult.ok) { + this.sendError("invalid_request_error", parseResult.error); + return; + } + + const frame = parseResult.data; + + if (frame.type === "response.create") { + // Clear first-frame timer + if (firstFrameTimer) { + clearTimeout(firstFrameTimer); + firstFrameTimer = null; + } + + if (this.state === "processing") { + this.sendError( + "conflict", + "A response is already in progress. Wait for the current response to complete before sending another request." + ); + return; + } + + this.state = "processing"; + this.turnCount++; + + this.currentMeta = { + model: frame.response.model, + serviceTier: frame.response.service_tier, + previousResponseId: frame.response.previous_response_id, + frame, + }; + + logger.debug("[WsIngress] Processing turn", { + turn: this.turnCount, + model: this.currentMeta.model, + previousResponseId: this.currentMeta.previousResponseId ? "[set]" : undefined, + serviceTier: this.currentMeta.serviceTier, + }); + + // Dispatch async handleTurn - state is managed by finally + this.handleTurn(frame) + .catch((err) => { + logger.error("[WsIngress] Turn failed", { error: err, turn: this.turnCount }); + this.sendError( + "server_error", + err instanceof Error ? err.message : "Internal server error" + ); + }) + .finally(() => { + if (this.state !== "closed") { + this.state = "waiting"; + this.currentMeta = null; + } + }); + return; + } + + if (frame.type === "response.cancel") { + if (this.state === "processing") { + logger.debug("[WsIngress] Cancel received for active turn", { turn: this.turnCount }); + // TODO (T7): Signal cancellation to outbound adapter + this.state = "waiting"; + this.currentMeta = null; + } else { + logger.debug("[WsIngress] Cancel received while idle (ignored)"); + } + return; + } + + // Unknown valid frame type while waiting - count toward buffer limit + bufferedNonCreateCount++; + if (bufferedNonCreateCount > this.options.maxBufferedFrames) { + this.sendError("invalid_request_error", "Too many frames before response.create"); + this.ws.close(1000); + this.state = "closed"; + } + }); + + this.ws.on("close", () => { + if (firstFrameTimer) clearTimeout(firstFrameTimer); + this.state = "closed"; + this.currentMeta = null; + logger.debug("[WsIngress] Connection closed", { turns: this.turnCount }); + }); + + this.ws.on("error", (err: Error) => { + if (firstFrameTimer) clearTimeout(firstFrameTimer); + this.state = "closed"; + this.currentMeta = null; + logger.error("[WsIngress] Connection error", { error: err.message }); + }); + } + + /** + * Handle a single response turn (delayed bridging). + * + * State management: the caller (.finally()) sets state back to "waiting". + * handleTurn does NOT manage connection state. + * + * For T6, this implements the delayed bridging skeleton. + * The actual outbound adapter integration happens in T7 (event bridge). + */ + async handleTurn(_frame: ResponseCreateFrame): Promise { + // TODO (T7/T8): Create synthetic ProxySession from this.req + this.auth + // TODO (T7/T8): Run deferred guard pipeline (model, rateLimit, provider) + // TODO (T7/T8): Use outbound adapter or HTTP fallback + // TODO (T7/T8): Relay events back to client + + // Placeholder: WS ingress operational but bridging not connected + this.sendError( + "server_error", + "WebSocket ingress operational but upstream bridging not yet implemented (pending T7/T8)" + ); + } + + /** Send an error frame to the client */ + private sendError(type: string, message: string): void { + if (this.ws.readyState === this.ws.OPEN) { + this.ws.send( + JSON.stringify({ + type: "error", + error: { type, message }, + }) + ); + } + } + + // --------------------------------------------------------------------------- + // Public accessors (for T7/T8/T9 integration and testing) + // --------------------------------------------------------------------------- + + /** Current connection state */ + get connectionState(): ConnectionState { + return this.state; + } + + /** Number of completed turns */ + get completedTurns(): number { + return this.turnCount; + } + + /** Current turn metadata (null when idle) */ + get currentTurnMeta(): TurnMeta | null { + return this.currentMeta; + } + + /** Authenticated identity (null before start()) */ + get authContext(): WsAuthContext | null { + return this.auth; + } + + /** Client IP address */ + get clientIp(): string { + return this.ip; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractClientIp(req: IncomingMessage): string { + const realIp = req.headers["x-real-ip"]; + if (typeof realIp === "string" && realIp.trim()) return realIp.trim(); + + const forwarded = req.headers["x-forwarded-for"]; + if (typeof forwarded === "string") { + const ips = forwarded + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ips.length > 0) return ips[ips.length - 1]; + } + + return req.socket?.remoteAddress ?? "unknown"; +} + +// --------------------------------------------------------------------------- +// Factory: register with WsManager +// --------------------------------------------------------------------------- + +/** + * Register the ingress handler with WsManager. + * Call during server startup to replace the placeholder handler. + */ +export function registerIngressHandler( + wsManager: import("@/server/ws-manager").WsManager, + options?: IngressHandlerOptions +): void { + wsManager.onConnection(async (ws, req) => { + const handler = new WsIngressHandler(ws, req, options); + const ok = await handler.start(); + if (!ok) { + logger.debug("[WsIngress] Connection rejected during init"); + } + }); +} diff --git a/src/app/v1/_lib/ws/outbound-adapter.ts b/src/app/v1/_lib/ws/outbound-adapter.ts new file mode 100644 index 000000000..c230a0737 --- /dev/null +++ b/src/app/v1/_lib/ws/outbound-adapter.ts @@ -0,0 +1,250 @@ +import WebSocket from "ws"; +import { logger } from "@/lib/logger"; +import { isTerminalEvent, parseServerError, parseTerminalEvent } from "@/lib/ws/frame-parser"; +import type { ResponseUsage, ServerErrorFrame, TerminalEvent } from "@/lib/ws/frames"; +import { toWebSocketUrl } from "../proxy/transport-classifier"; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface OutboundAdapterOptions { + /** Provider base URL (https://) - will be converted to wss:// */ + providerBaseUrl: string; + /** Bearer token for Authorization header */ + apiKey: string; + /** Handshake timeout in ms (default: 10_000) */ + handshakeTimeoutMs?: number; + /** Idle timeout after last event in ms (default: 60_000, flex: 300_000) */ + idleTimeoutMs?: number; + /** Custom headers to include in upgrade request */ + extraHeaders?: Record; +} + +// --------------------------------------------------------------------------- +// Result +// --------------------------------------------------------------------------- + +export interface OutboundTurnResult { + /** Whether the turn completed with a terminal event */ + completed: boolean; + /** Terminal event type if completed */ + terminalType?: string; + /** Terminal event data */ + terminalEvent?: TerminalEvent; + /** Usage from terminal event */ + usage?: ResponseUsage; + /** Model from terminal response */ + model?: string; + /** Service tier from terminal response */ + serviceTier?: string; + /** Prompt cache key from terminal response */ + promptCacheKey?: string; + /** Error if failed */ + error?: ServerErrorFrame | Error; + /** All server events received (for relay to client) */ + events: Array<{ type: string; data: unknown }>; + /** Handshake latency in ms */ + handshakeMs?: number; +} + +// --------------------------------------------------------------------------- +// Internal resolved options (all fields required) +// --------------------------------------------------------------------------- + +interface ResolvedOptions { + providerBaseUrl: string; + apiKey: string; + handshakeTimeoutMs: number; + idleTimeoutMs: number; + extraHeaders: Record; +} + +// --------------------------------------------------------------------------- +// Adapter +// --------------------------------------------------------------------------- + +/** + * Request-scoped outbound WebSocket adapter for OpenAI Responses API. + * + * One adapter instance per proxy request. NOT pooled or reused. + * Opens wss:// connection, sends response.create, collects events + * until terminal event or error. + */ +export class OutboundWsAdapter { + private ws: WebSocket | null = null; + private opts: ResolvedOptions; + + constructor(options: OutboundAdapterOptions) { + this.opts = { + providerBaseUrl: options.providerBaseUrl, + apiKey: options.apiKey, + handshakeTimeoutMs: options.handshakeTimeoutMs ?? 10_000, + idleTimeoutMs: options.idleTimeoutMs ?? 60_000, + extraHeaders: options.extraHeaders ?? {}, + }; + } + + /** + * Execute a single response turn over WebSocket. + * + * 1. Connect to wss://provider/v1/responses + * 2. Send response.create frame + * 3. Collect events until terminal (completed/failed/incomplete) or error + * 4. Close connection + * 5. Return result with usage/model/events + */ + async executeTurn(requestBody: Record): Promise { + const events: Array<{ type: string; data: unknown }> = []; + const wsUrl = toWebSocketUrl(this.opts.providerBaseUrl); + + return new Promise((resolve) => { + const handshakeStart = Date.now(); + let handshakeMs: number | undefined; + let resolved = false; + let idleTimer: NodeJS.Timeout | null = null; + + // ------------------------------------------------------------------ + // finish: single exit point (guards against double resolution) + // ------------------------------------------------------------------ + const finish = (partial: Partial) => { + if (resolved) return; + resolved = true; + if (idleTimer) clearTimeout(idleTimer); + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close(1000); + } + resolve({ + completed: false, + events, + handshakeMs, + ...partial, + }); + }; + + // ------------------------------------------------------------------ + // Idle timer: reset on every incoming event + // ------------------------------------------------------------------ + const resetIdleTimer = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + finish({ + error: new Error(`Idle timeout: no events for ${this.opts.idleTimeoutMs}ms`), + }); + }, this.opts.idleTimeoutMs); + }; + + // ------------------------------------------------------------------ + // Handshake timer + // ------------------------------------------------------------------ + const handshakeTimer = setTimeout(() => { + finish({ + error: new Error(`Handshake timeout: ${this.opts.handshakeTimeoutMs}ms`), + }); + }, this.opts.handshakeTimeoutMs); + + // ------------------------------------------------------------------ + // Open WS connection + // ------------------------------------------------------------------ + try { + this.ws = new WebSocket(wsUrl, { + headers: { + Authorization: `Bearer ${this.opts.apiKey}`, + ...this.opts.extraHeaders, + }, + handshakeTimeout: this.opts.handshakeTimeoutMs, + }); + + this.ws.on("open", () => { + clearTimeout(handshakeTimer); + handshakeMs = Date.now() - handshakeStart; + + // Send response.create frame + const frame = { + type: "response.create", + response: requestBody, + }; + this.ws!.send(JSON.stringify(frame)); + + // Start idle timer + resetIdleTimer(); + }); + + this.ws.on("message", (data: Buffer | string) => { + resetIdleTimer(); + + const raw = typeof data === "string" ? data : data.toString("utf-8"); + let parsed: Record; + try { + parsed = JSON.parse(raw); + } catch { + logger.warn("[OutboundWsAdapter] Non-JSON message received"); + return; + } + + const eventType = parsed.type as string; + events.push({ type: eventType, data: parsed }); + + // Check for error frame + if (eventType === "error") { + const errorResult = parseServerError(parsed); + finish({ + error: errorResult.ok ? errorResult.data : new Error("Unknown server error"), + }); + return; + } + + // Check for terminal event + if (isTerminalEvent(eventType)) { + const terminalResult = parseTerminalEvent(parsed); + if (terminalResult.ok) { + const te = terminalResult.data; + finish({ + completed: true, + terminalType: eventType, + terminalEvent: te, + usage: te.response.usage ?? undefined, + model: te.response.model ?? undefined, + serviceTier: te.response.service_tier ?? undefined, + promptCacheKey: te.response.prompt_cache_key ?? undefined, + }); + } else { + finish({ + completed: true, + terminalType: eventType, + error: new Error(`Terminal event parse error: ${terminalResult.error}`), + }); + } + return; + } + }); + + this.ws.on("error", (err: Error) => { + clearTimeout(handshakeTimer); + finish({ error: err }); + }); + + this.ws.on("close", (code: number, reason: Buffer) => { + clearTimeout(handshakeTimer); + if (!resolved) { + finish({ + error: new Error(`WebSocket closed unexpectedly: ${code} ${reason.toString()}`), + }); + } + }); + } catch (err) { + clearTimeout(handshakeTimer); + finish({ + error: err instanceof Error ? err : new Error(String(err)), + }); + } + }); + } + + /** Force close the connection */ + close(): void { + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.ws.close(1000); + } + } +} diff --git a/src/app/v1/_lib/ws/session-continuity.ts b/src/app/v1/_lib/ws/session-continuity.ts new file mode 100644 index 000000000..9e55d97aa --- /dev/null +++ b/src/app/v1/_lib/ws/session-continuity.ts @@ -0,0 +1,221 @@ +import { logger } from "@/lib/logger"; +import { SessionManager } from "@/lib/session-manager"; + +import type { SettlementResult } from "./event-bridge"; +import type { TurnMeta, WsAuthContext } from "./ingress-handler"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Phase of a WS turn lifecycle */ +export type TurnPhase = "setup" | "streaming" | "settled"; + +/** Classification of a disconnect event */ +export type DisconnectClassification = "retryable" | "terminal"; + +/** + * Per-turn context for WS session tracking. + * + * Created when a response.create frame starts a new turn, updated + * when the terminal event arrives with prompt_cache_key. + */ +export interface WsTurnContext { + /** Model requested for this turn */ + model: string; + /** Previous response ID from client request */ + previousResponseId: string | undefined; + /** Prompt cache key (populated from terminal event) */ + promptCacheKey: string | undefined; + /** Transport type (always "websocket" for WS turns) */ + transport: "websocket"; + /** Turn start timestamp */ + startedAt: number; + /** Key ID from auth context */ + keyId: number; + /** User ID from auth context */ + userId: number; +} + +// --------------------------------------------------------------------------- +// Upstream error codes that are explicit protocol errors. +// These are NEVER silently retried -- surfaced directly to the client. +// --------------------------------------------------------------------------- + +const EXPLICIT_PROTOCOL_ERRORS = new Set([ + "previous_response_not_found", + "websocket_connection_limit_reached", +]); + +// --------------------------------------------------------------------------- +// Transport / setup error patterns that qualify for neutral fallback. +// These indicate WS transport issues, NOT API-level errors. +// --------------------------------------------------------------------------- + +const TRANSPORT_ERROR_PATTERNS = [ + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "EHOSTUNREACH", + "ENOTFOUND", + "EPIPE", + "ECONNABORTED", + "handshake", + "upgrade", + "WebSocket", + "websocket", + "socket hang up", +] as const; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Create a turn-scoped context from auth and turn metadata. + * + * Called by the ingress handler when a response.create frame arrives + * and a new turn begins. + */ +export function createWsTurnContext(auth: WsAuthContext, turnMeta: TurnMeta): WsTurnContext { + return { + model: turnMeta.model, + previousResponseId: turnMeta.previousResponseId, + promptCacheKey: undefined, + transport: "websocket", + startedAt: Date.now(), + keyId: auth.key.id, + userId: auth.user.id, + }; +} + +/** + * Update session binding from a terminal event settlement. + * + * Extracts prompt_cache_key from the settlement and delegates to + * SessionManager.updateSessionWithCodexCacheKey() to create/refresh + * the session binding in Redis. + * + * @param turnContext - Mutable; promptCacheKey is written in-place. + * @param settlement - Terminal event settlement from event bridge. + * @param sessionId - Current proxy session ID (null if not yet determined). + * @param providerId - Provider ID used for this turn (null if not yet selected). + */ +export async function updateSessionFromTerminal( + turnContext: WsTurnContext, + settlement: SettlementResult, + sessionId: string | null, + providerId: number | null +): Promise<{ turnContext: WsTurnContext; sessionUpdated: boolean }> { + const promptCacheKey = settlement.promptCacheKey; + + if (!promptCacheKey) { + logger.debug("[SessionContinuity] No prompt_cache_key in settlement", { + status: settlement.status, + model: settlement.model, + }); + return { turnContext, sessionUpdated: false }; + } + + // Always populate turn context regardless of session binding outcome + turnContext.promptCacheKey = promptCacheKey; + + // Delegate to existing SessionManager for Redis binding + if (sessionId && providerId != null) { + try { + const result = await SessionManager.updateSessionWithCodexCacheKey( + sessionId, + promptCacheKey, + providerId + ); + + logger.debug("[SessionContinuity] Session binding updated from terminal", { + promptCacheKey, + sessionId: result.sessionId, + updated: result.updated, + providerId, + }); + + return { turnContext, sessionUpdated: result.updated }; + } catch (error) { + logger.error("[SessionContinuity] Failed to update session from terminal", { + error, + promptCacheKey, + sessionId, + providerId, + }); + return { turnContext, sessionUpdated: false }; + } + } + + return { turnContext, sessionUpdated: false }; +} + +/** + * Classify a disconnect based on the turn phase and optional error code. + * + * Boundary rules: + * - "setup" phase (before upstream event stream starts): + * retryable -- MAY fall back to HTTP (neutral fallback). + * - "streaming" phase (after upstream started sending events): + * terminal -- MUST fail with explicit error, no hidden HTTP replay. + * - "settled" phase (terminal event already received): + * terminal -- turn already completed, nothing to retry. + * - Explicit protocol errors (previous_response_not_found, + * websocket_connection_limit_reached): always terminal regardless + * of phase. + */ +export function classifyDisconnect( + turnPhase: TurnPhase, + errorCode?: string +): DisconnectClassification { + // Explicit protocol errors are always terminal + if (errorCode && EXPLICIT_PROTOCOL_ERRORS.has(errorCode)) { + return "terminal"; + } + + // Pre-stream: transport failures can retry via HTTP + if (turnPhase === "setup") { + return "retryable"; + } + + // Mid-stream or settled: no hidden HTTP replay + return "terminal"; +} + +/** + * Check whether an error qualifies for neutral transport fallback. + * + * "Neutral" means the error is a transport/setup issue, not an API error. + * Neutral fallback errors: + * - Do NOT count against the circuit breaker + * - MAY be retried transparently via HTTP + * - Match the existing `ws_fallback` reason in the provider chain taxonomy + * + * Non-neutral errors (API errors, explicit protocol errors) are surfaced + * directly to the client as protocol-level errors. + */ +export function isNeutralFallback( + error: Error | { type?: string; code?: string; message?: string } +): boolean { + const errorRecord = error as Record; + const code = typeof errorRecord.code === "string" ? errorRecord.code : undefined; + const type = typeof errorRecord.type === "string" ? errorRecord.type : undefined; + const message = error.message ?? ""; + + // Explicit protocol errors are never neutral (check code, type, AND message) + if (code && EXPLICIT_PROTOCOL_ERRORS.has(code)) { + return false; + } + if (type && EXPLICIT_PROTOCOL_ERRORS.has(type)) { + return false; + } + for (const explicitError of EXPLICIT_PROTOCOL_ERRORS) { + if (message.includes(explicitError)) { + return false; + } + } + + // Check message against transport/setup error patterns + return TRANSPORT_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); +} diff --git a/tests/unit/ws/event-bridge.test.ts b/tests/unit/ws/event-bridge.test.ts new file mode 100644 index 000000000..c6cfb293e --- /dev/null +++ b/tests/unit/ws/event-bridge.test.ts @@ -0,0 +1,598 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mock: logger +// --------------------------------------------------------------------------- + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +import { + WsEventBridge, + type SettlementResult, + type SettlementStatus, + type EventBridgeOptions, +} from "@/app/v1/_lib/ws/event-bridge"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const WS_OPEN = 1; +const WS_CLOSED = 3; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockWs(readyState = WS_OPEN) { + return { + readyState, + OPEN: WS_OPEN, + send: vi.fn(), + } as any; +} + +function makeNonTerminalEvent(type = "response.output_text.delta") { + const data = { type, delta: "hello", item_id: "item_1", output_index: 0, content_index: 0 }; + return { + eventData: { type, data }, + rawJson: JSON.stringify(data), + }; +} + +function makeCreatedEvent() { + const type = "response.created"; + const data = { type, response: { id: "resp_123", status: "in_progress" } }; + return { + eventData: { type, data }, + rawJson: JSON.stringify(data), + }; +} + +function makeTerminalEvent( + status: "completed" | "failed" | "incomplete" = "completed", + responseOverrides?: Record +) { + const type = `response.${status}`; + const response = { + id: "resp_123", + status, + model: "gpt-4o", + service_tier: "default", + prompt_cache_key: "cache-key-001", + usage: { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }, + ...responseOverrides, + }; + const data = { type, response }; + return { + eventData: { type, data }, + rawJson: JSON.stringify(data), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("WsEventBridge", () => { + let bridge: WsEventBridge; + + beforeEach(() => { + bridge = new WsEventBridge(); + }); + + // ========================================================================= + // relayEvent: forwarding + // ========================================================================= + + describe("relayEvent forwarding", () => { + it("forwards raw JSON to client WS when OPEN", () => { + const ws = createMockWs(WS_OPEN); + const { eventData, rawJson } = makeNonTerminalEvent(); + + bridge.relayEvent(ws, eventData, rawJson); + + expect(ws.send).toHaveBeenCalledOnce(); + expect(ws.send).toHaveBeenCalledWith(rawJson); + }); + + it("does NOT send when client WS is not OPEN", () => { + const ws = createMockWs(WS_CLOSED); + const { eventData, rawJson } = makeNonTerminalEvent(); + + bridge.relayEvent(ws, eventData, rawJson); + + expect(ws.send).not.toHaveBeenCalled(); + }); + + it("forwards raw JSON unchanged (no re-serialization)", () => { + const ws = createMockWs(WS_OPEN); + const customRawJson = '{"type":"response.created","response":{"id":"resp_abc"}}'; + const eventData = { type: "response.created", data: JSON.parse(customRawJson) }; + + bridge.relayEvent(ws, eventData, customRawJson); + + expect(ws.send).toHaveBeenCalledWith(customRawJson); + }); + }); + + // ========================================================================= + // Ring buffer: bounded behavior + // ========================================================================= + + describe("ring buffer bounded behavior", () => { + it("stays bounded at maxBufferSize under burst", () => { + const smallBridge = new WsEventBridge({ maxBufferSize: 50 }); + const ws = createMockWs(); + + // Send 200 events into a buffer of size 50 + for (let i = 0; i < 200; i++) { + const { eventData, rawJson } = makeNonTerminalEvent(`event_${i}`); + smallBridge.relayEvent(ws, eventData, rawJson); + } + + const recent = smallBridge.getRecentEvents(); + expect(recent).toHaveLength(50); + expect(smallBridge.totalEvents).toBe(200); + }); + + it("overwrites oldest entries correctly (verify chronological order)", () => { + const tinyBridge = new WsEventBridge({ maxBufferSize: 3 }); + const ws = createMockWs(); + + // Send 5 events, buffer size 3 => should keep last 3 + for (let i = 0; i < 5; i++) { + const { eventData, rawJson } = makeNonTerminalEvent(`event_${i}`); + tinyBridge.relayEvent(ws, eventData, rawJson); + } + + const recent = tinyBridge.getRecentEvents(); + expect(recent).toHaveLength(3); + // Should be in chronological order: event_2, event_3, event_4 + expect(recent[0].type).toBe("event_2"); + expect(recent[1].type).toBe("event_3"); + expect(recent[2].type).toBe("event_4"); + }); + + it("getRecentEvents returns events in chronological order when buffer not full", () => { + const ws = createMockWs(); + + bridge.relayEvent(ws, { type: "a", data: { type: "a" } }, '{"type":"a"}'); + bridge.relayEvent(ws, { type: "b", data: { type: "b" } }, '{"type":"b"}'); + + const recent = bridge.getRecentEvents(); + expect(recent).toHaveLength(2); + expect(recent[0].type).toBe("a"); + expect(recent[1].type).toBe("b"); + }); + + it("getRecentEvents returns empty array before any events", () => { + const recent = bridge.getRecentEvents(); + expect(recent).toHaveLength(0); + }); + + it("uses default maxBufferSize of 100", () => { + const ws = createMockWs(); + + for (let i = 0; i < 150; i++) { + const { eventData, rawJson } = makeNonTerminalEvent(`ev_${i}`); + bridge.relayEvent(ws, eventData, rawJson); + } + + const recent = bridge.getRecentEvents(); + expect(recent).toHaveLength(100); + }); + }); + + // ========================================================================= + // Terminal event detection and settlement + // ========================================================================= + + describe("terminal event detection", () => { + it.each([ + { status: "completed" as const, expectedStatus: "completed" }, + { status: "failed" as const, expectedStatus: "failed" }, + { status: "incomplete" as const, expectedStatus: "incomplete" }, + ])("response.$status terminal event extracts settlement data (status=$expectedStatus)", ({ + status, + expectedStatus, + }) => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent(status); + + const isTerminal = bridge.relayEvent(ws, eventData, rawJson); + + expect(isTerminal).toBe(true); + const settlement = bridge.getSettlement(); + expect(settlement).not.toBeNull(); + expect(settlement!.status).toBe(expectedStatus); + }); + + it("response.completed terminal event extracts usage, model, serviceTier, promptCacheKey", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("completed"); + + bridge.relayEvent(ws, eventData, rawJson); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("completed"); + expect(settlement.usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }); + expect(settlement.model).toBe("gpt-4o"); + expect(settlement.serviceTier).toBe("default"); + expect(settlement.promptCacheKey).toBe("cache-key-001"); + expect(settlement.terminalType).toBe("response.completed"); + }); + + it("response.failed terminal event sets status to failed", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("failed"); + + bridge.relayEvent(ws, eventData, rawJson); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("failed"); + expect(settlement.terminalType).toBe("response.failed"); + }); + + it("response.incomplete terminal event sets status to incomplete", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("incomplete"); + + bridge.relayEvent(ws, eventData, rawJson); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("incomplete"); + expect(settlement.terminalType).toBe("response.incomplete"); + }); + + it("relayEvent returns true for terminal events, false otherwise", () => { + const ws = createMockWs(); + + // Non-terminal + const delta = makeNonTerminalEvent(); + expect(bridge.relayEvent(ws, delta.eventData, delta.rawJson)).toBe(false); + + const created = makeCreatedEvent(); + expect(bridge.relayEvent(ws, created.eventData, created.rawJson)).toBe(false); + + // Terminal + const terminal = makeTerminalEvent("completed"); + expect(bridge.relayEvent(ws, terminal.eventData, terminal.rawJson)).toBe(true); + }); + + it("usage is ONLY extracted from terminal events, not from deltas", () => { + const ws = createMockWs(); + + // Send a non-terminal event that happens to have usage-like data + const fakeUsageEvent = { + type: "response.created", + data: { + type: "response.created", + response: { + id: "resp_1", + status: "in_progress", + usage: { input_tokens: 999, output_tokens: 999 }, + }, + }, + }; + bridge.relayEvent(ws, fakeUsageEvent, JSON.stringify(fakeUsageEvent.data)); + + // No settlement yet + expect(bridge.getSettlement()).toBeNull(); + expect(bridge.isSettled).toBe(false); + + // Now send terminal event with real usage + const { eventData, rawJson } = makeTerminalEvent("completed"); + bridge.relayEvent(ws, eventData, rawJson); + + const settlement = bridge.getSettlement()!; + expect(settlement.usage!.input_tokens).toBe(100); + expect(settlement.usage!.output_tokens).toBe(50); + }); + + it("handles terminal event without usage gracefully", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("completed", { usage: undefined }); + + bridge.relayEvent(ws, eventData, rawJson); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("completed"); + expect(settlement.usage).toBeUndefined(); + }); + + it("handles malformed terminal event data (parse error)", () => { + const ws = createMockWs(); + // A terminal event type but with bad response structure + const badData = { type: "response.completed", response: "not-an-object" }; + const eventData = { type: "response.completed", data: badData }; + + bridge.relayEvent(ws, eventData, JSON.stringify(badData)); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("error"); + expect(settlement.errorMessage).toContain("Terminal event parse error"); + expect(settlement.terminalType).toBe("response.completed"); + }); + }); + + // ========================================================================= + // settleError + // ========================================================================= + + describe("settleError", () => { + it("records disconnection when no terminal event", () => { + bridge.settleError("WebSocket closed unexpectedly", "disconnected"); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("disconnected"); + expect(settlement.errorMessage).toBe("WebSocket closed unexpectedly"); + expect(settlement.eventCount).toBe(0); + }); + + it("records error with default status", () => { + bridge.settleError("Something went wrong"); + + const settlement = bridge.getSettlement()!; + expect(settlement.status).toBe("error"); + expect(settlement.errorMessage).toBe("Something went wrong"); + }); + + it("does not overwrite existing settlement", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("completed"); + + // Settle via terminal event first + bridge.relayEvent(ws, eventData, rawJson); + expect(bridge.getSettlement()!.status).toBe("completed"); + + // Attempt to overwrite with error + bridge.settleError("late error"); + + // Original settlement preserved + expect(bridge.getSettlement()!.status).toBe("completed"); + }); + + it("includes duration from first event when available", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeNonTerminalEvent(); + + // Send an event to set startTime + bridge.relayEvent(ws, eventData, rawJson); + + bridge.settleError("disconnect"); + + const settlement = bridge.getSettlement()!; + expect(settlement.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("durationMs is 0 when no events were received", () => { + bridge.settleError("immediate disconnect"); + + const settlement = bridge.getSettlement()!; + expect(settlement.durationMs).toBe(0); + }); + }); + + // ========================================================================= + // getSettlement + // ========================================================================= + + describe("getSettlement", () => { + it("returns null before any terminal event or error", () => { + expect(bridge.getSettlement()).toBeNull(); + }); + + it("returns settlement after terminal event", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("completed"); + + bridge.relayEvent(ws, eventData, rawJson); + + expect(bridge.getSettlement()).not.toBeNull(); + }); + }); + + // ========================================================================= + // isSettled + // ========================================================================= + + describe("isSettled", () => { + it("is false before terminal event", () => { + expect(bridge.isSettled).toBe(false); + }); + + it("is false after only non-terminal events", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeNonTerminalEvent(); + + bridge.relayEvent(ws, eventData, rawJson); + + expect(bridge.isSettled).toBe(false); + }); + + it("is true after terminal event", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("completed"); + + bridge.relayEvent(ws, eventData, rawJson); + + expect(bridge.isSettled).toBe(true); + }); + + it("is true after settleError", () => { + bridge.settleError("error"); + + expect(bridge.isSettled).toBe(true); + }); + }); + + // ========================================================================= + // totalEvents + // ========================================================================= + + describe("totalEvents", () => { + it("counts all events including non-terminal", () => { + const ws = createMockWs(); + + bridge.relayEvent(ws, makeCreatedEvent().eventData, makeCreatedEvent().rawJson); + bridge.relayEvent(ws, makeNonTerminalEvent().eventData, makeNonTerminalEvent().rawJson); + bridge.relayEvent( + ws, + makeNonTerminalEvent("response.output_text.done").eventData, + makeNonTerminalEvent("response.output_text.done").rawJson + ); + bridge.relayEvent( + ws, + makeTerminalEvent("completed").eventData, + makeTerminalEvent("completed").rawJson + ); + + expect(bridge.totalEvents).toBe(4); + }); + + it("starts at zero", () => { + expect(bridge.totalEvents).toBe(0); + }); + }); + + // ========================================================================= + // durationMs + // ========================================================================= + + describe("durationMs", () => { + it("measures from first event to terminal", () => { + const ws = createMockWs(); + + // First event + const { eventData: ev1, rawJson: rj1 } = makeNonTerminalEvent(); + bridge.relayEvent(ws, ev1, rj1); + + // Terminal event + const { eventData: ev2, rawJson: rj2 } = makeTerminalEvent("completed"); + bridge.relayEvent(ws, ev2, rj2); + + const settlement = bridge.getSettlement()!; + // durationMs should be >= 0 (nearly instant in test) + expect(settlement.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("durationMs is measured from first event even when terminal is the only event", () => { + const ws = createMockWs(); + const { eventData, rawJson } = makeTerminalEvent("completed"); + + bridge.relayEvent(ws, eventData, rawJson); + + const settlement = bridge.getSettlement()!; + expect(settlement.durationMs).toBeGreaterThanOrEqual(0); + }); + }); + + // ========================================================================= + // reset + // ========================================================================= + + describe("reset", () => { + it("clears all state for sequential turn reuse", () => { + const ws = createMockWs(); + + // First turn: send events and settle + bridge.relayEvent(ws, makeNonTerminalEvent().eventData, makeNonTerminalEvent().rawJson); + bridge.relayEvent( + ws, + makeTerminalEvent("completed").eventData, + makeTerminalEvent("completed").rawJson + ); + + expect(bridge.isSettled).toBe(true); + expect(bridge.totalEvents).toBe(2); + expect(bridge.getRecentEvents()).toHaveLength(2); + + // Reset + bridge.reset(); + + // All state cleared + expect(bridge.isSettled).toBe(false); + expect(bridge.totalEvents).toBe(0); + expect(bridge.getSettlement()).toBeNull(); + expect(bridge.getRecentEvents()).toHaveLength(0); + }); + + it("allows new events after reset", () => { + const ws = createMockWs(); + + // First turn + bridge.relayEvent( + ws, + makeTerminalEvent("completed").eventData, + makeTerminalEvent("completed").rawJson + ); + expect(bridge.isSettled).toBe(true); + + // Reset and new turn + bridge.reset(); + + const { eventData, rawJson } = makeTerminalEvent("failed"); + bridge.relayEvent(ws, eventData, rawJson); + + expect(bridge.isSettled).toBe(true); + expect(bridge.getSettlement()!.status).toBe("failed"); + expect(bridge.totalEvents).toBe(1); + }); + }); + + // ========================================================================= + // eventCount in settlement + // ========================================================================= + + describe("settlement eventCount", () => { + it("includes all events in settlement eventCount", () => { + const ws = createMockWs(); + + for (let i = 0; i < 10; i++) { + const { eventData, rawJson } = makeNonTerminalEvent(`delta_${i}`); + bridge.relayEvent(ws, eventData, rawJson); + } + + const { eventData, rawJson } = makeTerminalEvent("completed"); + bridge.relayEvent(ws, eventData, rawJson); + + expect(bridge.getSettlement()!.eventCount).toBe(11); + }); + }); + + // ========================================================================= + // Custom options + // ========================================================================= + + describe("custom options", () => { + it("respects custom maxBufferSize", () => { + const customBridge = new WsEventBridge({ maxBufferSize: 10 }); + const ws = createMockWs(); + + for (let i = 0; i < 25; i++) { + const { eventData, rawJson } = makeNonTerminalEvent(`ev_${i}`); + customBridge.relayEvent(ws, eventData, rawJson); + } + + expect(customBridge.getRecentEvents()).toHaveLength(10); + expect(customBridge.totalEvents).toBe(25); + }); + }); +}); diff --git a/tests/unit/ws/ingress-handler.test.ts b/tests/unit/ws/ingress-handler.test.ts new file mode 100644 index 000000000..4c90023fb --- /dev/null +++ b/tests/unit/ws/ingress-handler.test.ts @@ -0,0 +1,597 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage } from "node:http"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mock dependencies - factories return vi.fn() stubs. +// mockReset:true resets them between tests; beforeEach re-sets defaults. +// --------------------------------------------------------------------------- + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: vi.fn(), +})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + isResponsesWebSocketEnabled: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/proxy/auth-guard", () => ({ + extractApiKeyFromHeaders: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { extractApiKeyFromHeaders } from "@/app/v1/_lib/proxy/auth-guard"; +import { + WsIngressHandler, + registerIngressHandler, + type ConnectionState, +} from "@/app/v1/_lib/ws/ingress-handler"; +import { isResponsesWebSocketEnabled } from "@/lib/config/system-settings-cache"; +import { validateApiKeyAndGetUser } from "@/repository/key"; + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const validUser = { id: 1, name: "test-user", isEnabled: true, role: "user" }; +const validKey = { id: 10, name: "test-key", userId: 1, isEnabled: true }; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WS_OPEN = 1; +const WS_CLOSED = 3; + +function createMockWs(readyState = WS_OPEN) { + const ws = new EventEmitter() as EventEmitter & { + readyState: number; + OPEN: number; + send: ReturnType; + close: ReturnType; + }; + ws.readyState = readyState; + ws.OPEN = WS_OPEN; + ws.send = vi.fn(); + ws.close = vi.fn(); + return ws; +} + +function createMockReq( + headers: Record = {}, + remoteAddress = "127.0.0.1" +): IncomingMessage { + return { + url: "/v1/responses", + headers: { + host: "localhost:13500", + authorization: "Bearer test-key", + ...headers, + }, + socket: { remoteAddress }, + } as unknown as IncomingMessage; +} + +function makeCreateFrame(model = "o3-pro", overrides: Record = {}): string { + return JSON.stringify({ + type: "response.create", + response: { model, ...overrides }, + }); +} + +function makeCancelFrame(): string { + return JSON.stringify({ type: "response.cancel" }); +} + +/** Flush the microtask queue (3 levels covers promise chains) */ +async function flush(): Promise { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +} + +/** Parse the last sent JSON from ws.send */ +function lastSentJson(ws: ReturnType): Record | null { + const calls = ws.send.mock.calls; + if (calls.length === 0) return null; + return JSON.parse(calls[calls.length - 1][0] as string) as Record; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.mocked(isResponsesWebSocketEnabled).mockResolvedValue(true); + vi.mocked(extractApiKeyFromHeaders).mockReturnValue("test-api-key"); + vi.mocked(validateApiKeyAndGetUser).mockResolvedValue({ + user: validUser as any, + key: validKey as any, + }); +}); + +// =========================================================================== +// WsIngressHandler +// =========================================================================== + +describe("WsIngressHandler", () => { + // ------------------------------------------------------------------------- + // Auth and toggle + // ------------------------------------------------------------------------- + + describe("auth and toggle", () => { + test("authenticates at start time and sets up listeners", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + const ok = await handler.start(); + + expect(ok).toBe(true); + expect(handler.connectionState).toBe("waiting" satisfies ConnectionState); + expect(handler.authContext).toBeTruthy(); + expect(handler.authContext!.user.id).toBe(1); + expect(handler.authContext!.key.id).toBe(10); + }); + + test("closes with 4001 when no API key provided", async () => { + vi.mocked(extractApiKeyFromHeaders).mockReturnValue(null); + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + const ok = await handler.start(); + + expect(ok).toBe(false); + expect(ws.close).toHaveBeenCalledWith(4001, expect.stringContaining("credentials")); + expect(handler.connectionState).toBe("closed"); + }); + + test("closes with 4001 when API key validation fails", async () => { + vi.mocked(validateApiKeyAndGetUser).mockResolvedValue(null); + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + const ok = await handler.start(); + + expect(ok).toBe(false); + expect(ws.close).toHaveBeenCalledWith(4001, expect.stringContaining("invalid")); + expect(handler.connectionState).toBe("closed"); + }); + + test("closes with 4001 when user is disabled", async () => { + vi.mocked(validateApiKeyAndGetUser).mockResolvedValue({ + user: { ...validUser, isEnabled: false } as any, + key: validKey as any, + }); + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + const ok = await handler.start(); + + expect(ok).toBe(false); + expect(ws.close).toHaveBeenCalledWith(4001, expect.stringContaining("disabled")); + }); + + test("closes with 4003 when WS toggle is disabled", async () => { + vi.mocked(isResponsesWebSocketEnabled).mockResolvedValue(false); + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + const ok = await handler.start(); + + expect(ok).toBe(false); + expect(ws.close).toHaveBeenCalledWith(4003, expect.stringContaining("disabled")); + }); + + test("exposes client IP from socket remote address", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq({}, "10.0.0.5")); + await handler.start(); + + expect(handler.clientIp).toBe("10.0.0.5"); + }); + + test("prefers x-real-ip for client IP", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler( + ws as any, + createMockReq({ "x-real-ip": "203.0.113.50" }) + ); + await handler.start(); + + expect(handler.clientIp).toBe("203.0.113.50"); + }); + }); + + // ------------------------------------------------------------------------- + // State transitions + // ------------------------------------------------------------------------- + + describe("state transitions", () => { + test("transitions to processing on valid response.create", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + + // Synchronously in processing state + expect(handler.connectionState).toBe("processing"); + }); + + test("extracts model from response.create", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("o3-pro")); + + expect(handler.currentTurnMeta?.model).toBe("o3-pro"); + }); + + test("extracts service_tier from response.create", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("o3-pro", { service_tier: "flex" })); + + expect(handler.currentTurnMeta?.serviceTier).toBe("flex"); + }); + + test("extracts previous_response_id from response.create", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("o3-pro", { previous_response_id: "resp_abc" })); + + expect(handler.currentTurnMeta?.previousResponseId).toBe("resp_abc"); + }); + + test("returns to waiting after handleTurn completes", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + expect(handler.connectionState).toBe("processing"); + + await flush(); + + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // Concurrent in-flight rejection + // ------------------------------------------------------------------------- + + describe("concurrent in-flight rejection", () => { + test("rejects second response.create while processing", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + // First creates -> processing (synchronous) + ws.emit("message", makeCreateFrame()); + expect(handler.connectionState).toBe("processing"); + + // Second create while processing -> conflict error + ws.emit("message", makeCreateFrame("o3-mini")); + + const calls = ws.send.mock.calls; + const conflictMsg = calls.find((c: unknown[]) => { + const parsed = JSON.parse(c[0] as string) as Record; + return (parsed.error as Record)?.type === "conflict"; + }); + expect(conflictMsg).toBeDefined(); + }); + + test("does not close socket on concurrent rejection (recoverable)", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + ws.emit("message", makeCreateFrame()); + + expect(ws.close).not.toHaveBeenCalled(); + expect(handler.connectionState).toBe("processing"); + }); + }); + + // ------------------------------------------------------------------------- + // Sequential turns + // ------------------------------------------------------------------------- + + describe("sequential turns", () => { + test("allows new response.create after turn completes", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + // Turn 1 + ws.emit("message", makeCreateFrame("o3-pro")); + await flush(); + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(1); + + // Turn 2 + ws.emit("message", makeCreateFrame("o3-mini")); + await flush(); + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(2); + }); + + test("clears turn metadata between turns", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("o3-pro", { service_tier: "flex" })); + await flush(); + + expect(handler.currentTurnMeta).toBeNull(); + + ws.emit("message", makeCreateFrame("o3-mini")); + expect(handler.currentTurnMeta?.model).toBe("o3-mini"); + expect(handler.currentTurnMeta?.serviceTier).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // response.cancel + // ------------------------------------------------------------------------- + + describe("response.cancel", () => { + test("transitions from processing to waiting on cancel", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + expect(handler.connectionState).toBe("processing"); + + ws.emit("message", makeCancelFrame()); + expect(handler.connectionState).toBe("waiting"); + expect(handler.currentTurnMeta).toBeNull(); + }); + + test("cancel while idle is silently ignored (no error)", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCancelFrame()); + + expect(handler.connectionState).toBe("waiting"); + expect(ws.send).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Invalid frames + // ------------------------------------------------------------------------- + + describe("invalid frame handling", () => { + test("sends error on invalid JSON", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", "not valid json{{{"); + + expect(handler.connectionState).toBe("waiting"); + expect(ws.close).not.toHaveBeenCalled(); + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("invalid_request_error"); + }); + + test("sends error on missing model in response.create", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", JSON.stringify({ type: "response.create", response: {} })); + + expect(handler.connectionState).toBe("waiting"); + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("invalid_request_error"); + }); + + test("sends error on unknown frame type", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", JSON.stringify({ type: "session.update" })); + + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("invalid_request_error"); + }); + }); + + // ------------------------------------------------------------------------- + // Socket lifecycle + // ------------------------------------------------------------------------- + + describe("socket lifecycle", () => { + test("connection close sets state to closed", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("close"); + + expect(handler.connectionState).toBe("closed"); + }); + + test("connection error sets state to closed", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("error", new Error("socket hang up")); + + expect(handler.connectionState).toBe("closed"); + }); + + test("messages received after close are ignored", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("close"); + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(handler.completedTurns).toBe(0); + }); + + test("sendError skips when readyState is not OPEN", async () => { + const ws = createMockWs(WS_CLOSED); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", "bad json"); + await flush(); + + expect(ws.send).not.toHaveBeenCalled(); + }); + + test("handleTurn error sends server_error and returns to waiting", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + // Override handleTurn to throw + handler.handleTurn = async () => { + throw new Error("upstream exploded"); + }; + + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(handler.connectionState).toBe("waiting"); + const calls = ws.send.mock.calls; + const errorMsg = calls.find((c: unknown[]) => { + const parsed = JSON.parse(c[0] as string) as Record; + return ( + (parsed.error as Record)?.type === "server_error" && + ((parsed.error as Record)?.message as string)?.includes( + "upstream exploded" + ) + ); + }); + expect(errorMsg).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // First-frame timeout + // ------------------------------------------------------------------------- + + describe("first-frame timeout", () => { + test("fires when no response.create received", async () => { + vi.useFakeTimers(); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq(), { + firstFrameTimeoutMs: 5000, + }); + await handler.start(); + + vi.advanceTimersByTime(5001); + + expect(handler.connectionState).toBe("closed"); + expect(ws.close).toHaveBeenCalledWith(1000); + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("timeout"); + + vi.useRealTimers(); + }); + + test("is cleared when response.create arrives in time", async () => { + vi.useFakeTimers(); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq(), { + firstFrameTimeoutMs: 5000, + }); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + // Advance well past the timeout + vi.advanceTimersByTime(10000); + + // State should not be closed (timer was cleared) + expect(handler.connectionState).not.toBe("closed"); + expect(ws.close).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); +}); + +// =========================================================================== +// registerIngressHandler +// =========================================================================== + +describe("registerIngressHandler", () => { + test("wires handler to WsManager onConnection", async () => { + let capturedHandler: ((ws: any, req: any) => Promise) | null = null; + const mockManager = { + onConnection: vi.fn((handler: (ws: any, req: any) => Promise) => { + capturedHandler = handler; + }), + }; + + registerIngressHandler(mockManager as any); + + expect(mockManager.onConnection).toHaveBeenCalledOnce(); + expect(capturedHandler).toBeTypeOf("function"); + + // Call the handler - should create WsIngressHandler and start it + const ws = createMockWs(); + await capturedHandler!(ws, createMockReq()); + + // After successful start(), listeners should be set up + expect(ws.listenerCount("message")).toBeGreaterThan(0); + expect(ws.listenerCount("close")).toBeGreaterThan(0); + expect(ws.listenerCount("error")).toBeGreaterThan(0); + }); + + test("rejected connection does not set up listeners", async () => { + vi.mocked(isResponsesWebSocketEnabled).mockResolvedValue(false); + + let capturedHandler: ((ws: any, req: any) => Promise) | null = null; + const mockManager = { + onConnection: vi.fn((handler: (ws: any, req: any) => Promise) => { + capturedHandler = handler; + }), + }; + + registerIngressHandler(mockManager as any); + + const ws = createMockWs(); + await capturedHandler!(ws, createMockReq()); + + // No message listeners - connection was rejected + expect(ws.listenerCount("message")).toBe(0); + expect(ws.close).toHaveBeenCalledWith(4003, expect.any(String)); + }); +}); diff --git a/tests/unit/ws/outbound-adapter.test.ts b/tests/unit/ws/outbound-adapter.test.ts new file mode 100644 index 000000000..9bb32b3c3 --- /dev/null +++ b/tests/unit/ws/outbound-adapter.test.ts @@ -0,0 +1,574 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoisted helpers (accessible inside vi.mock factories) +// --------------------------------------------------------------------------- + +const { getMockInstance, setMockInstance, resetMockInstance } = vi.hoisted(() => { + let instance: MockWsType | null = null; + + // Minimal type for the mock instance (full definition below) + interface MockWsType { + url: string; + options?: Record; + readyState: number; + send: ReturnType; + close: ReturnType; + on: (event: string, fn: (...args: unknown[]) => void) => MockWsType; + emit: (event: string, ...args: unknown[]) => void; + } + + return { + getMockInstance: (): MockWsType | null => instance, + setMockInstance: (i: MockWsType) => { + instance = i; + }, + resetMockInstance: () => { + instance = null; + }, + }; +}); + +// --------------------------------------------------------------------------- +// Mock: ws +// --------------------------------------------------------------------------- + +vi.mock("ws", () => { + type ListenerFn = (...args: unknown[]) => void; + + class MockWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + readyState = 1; // OPEN + send = vi.fn(); + close = vi.fn(() => { + this.readyState = 3; // CLOSED + }); + + url: string; + options?: Record; + + private _listeners: Record = {}; + + constructor(url: string, options?: Record) { + this.url = url; + this.options = options; + setMockInstance(this as unknown as Parameters[0]); + } + + on(event: string, fn: ListenerFn) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(fn); + return this; + } + + emit(event: string, ...args: unknown[]) { + for (const fn of this._listeners[event] ?? []) { + fn(...args); + } + } + } + + return { default: MockWebSocket }; +}); + +// --------------------------------------------------------------------------- +// Mock: transport-classifier (has "server-only" import) +// --------------------------------------------------------------------------- + +vi.mock("@/app/v1/_lib/proxy/transport-classifier", () => ({ + toWebSocketUrl: (url: string) => + url.replace("https://", "wss://").replace(/\/$/, "") + "/v1/responses", +})); + +// --------------------------------------------------------------------------- +// Mock: logger +// --------------------------------------------------------------------------- + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Import SUT (after all mocks) +// --------------------------------------------------------------------------- + +import { OutboundWsAdapter, type OutboundAdapterOptions } from "@/app/v1/_lib/ws/outbound-adapter"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function defaultOptions(overrides?: Partial): OutboundAdapterOptions { + return { + providerBaseUrl: "https://api.openai.com", + apiKey: "sk-test-key-123", + ...overrides, + }; +} + +function makeCompletedEvent(overrides?: Record) { + return JSON.stringify({ + type: "response.completed", + response: { + id: "resp_abc123", + status: "completed", + model: "gpt-4o", + service_tier: "default", + prompt_cache_key: "cache-key-001", + usage: { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }, + output: [], + ...overrides, + }, + }); +} + +function makeFailedEvent(overrides?: Record) { + return JSON.stringify({ + type: "response.failed", + response: { + id: "resp_fail123", + status: "failed", + ...overrides, + }, + }); +} + +function makeIncompleteEvent(overrides?: Record) { + return JSON.stringify({ + type: "response.incomplete", + response: { + id: "resp_inc123", + status: "incomplete", + ...overrides, + }, + }); +} + +function makeErrorFrame(overrides?: Record) { + return JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_model", + message: "Model not found", + ...overrides, + }, + }); +} + +function makeDeltaEvent(text: string) { + return JSON.stringify({ + type: "response.output_text.delta", + delta: text, + item_id: "item_001", + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("OutboundWsAdapter", () => { + beforeEach(() => { + vi.useFakeTimers(); + resetMockInstance(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ========================================================================= + // Connection & Frame + // ========================================================================= + + it("sends response.create frame on open with correct Authorization header", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const requestBody = { model: "gpt-4o", input: [] }; + const turnPromise = adapter.executeTurn(requestBody); + + const ws = getMockInstance()!; + expect(ws).toBeTruthy(); + + // Verify WS URL + expect(ws.url).toBe("wss://api.openai.com/v1/responses"); + + // Verify Authorization header + const headers = (ws.options as Record).headers as Record; + expect(headers.Authorization).toBe("Bearer sk-test-key-123"); + + // Simulate open -> adapter sends frame + ws.emit("open"); + + expect(ws.send).toHaveBeenCalledOnce(); + const sentFrame = JSON.parse(ws.send.mock.calls[0][0] as string); + expect(sentFrame.type).toBe("response.create"); + expect(sentFrame.response).toEqual(requestBody); + + // Complete the turn + ws.emit("message", makeCompletedEvent()); + + const result = await turnPromise; + expect(result.completed).toBe(true); + }); + + it("preserves model, service_tier, reasoning, previous_response_id, parallel_tool_calls in the frame", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const requestBody = { + model: "gpt-5-codex", + input: [{ type: "message", role: "user", content: "hello" }], + service_tier: "flex", + reasoning: { effort: "high", summary: "auto", encrypted_content: "abc123" }, + previous_response_id: "resp_prev_001", + parallel_tool_calls: true, + prompt_cache_key: "019b82ff-08ff-75a3", + }; + + const turnPromise = adapter.executeTurn(requestBody); + + const ws = getMockInstance()!; + ws.emit("open"); + + const sentFrame = JSON.parse(ws.send.mock.calls[0][0] as string); + expect(sentFrame.response.model).toBe("gpt-5-codex"); + expect(sentFrame.response.service_tier).toBe("flex"); + expect(sentFrame.response.reasoning).toEqual({ + effort: "high", + summary: "auto", + encrypted_content: "abc123", + }); + expect(sentFrame.response.previous_response_id).toBe("resp_prev_001"); + expect(sentFrame.response.parallel_tool_calls).toBe(true); + expect(sentFrame.response.prompt_cache_key).toBe("019b82ff-08ff-75a3"); + + ws.emit("message", makeCompletedEvent()); + await turnPromise; + }); + + it("passes stream:false (generate:false) through in the frame", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const requestBody = { model: "gpt-4o", input: [], stream: false }; + + const turnPromise = adapter.executeTurn(requestBody); + + const ws = getMockInstance()!; + ws.emit("open"); + + const sentFrame = JSON.parse(ws.send.mock.calls[0][0] as string); + expect(sentFrame.response.stream).toBe(false); + + ws.emit("message", makeCompletedEvent()); + await turnPromise; + }); + + it("passes extra headers to WebSocket constructor", async () => { + const adapter = new OutboundWsAdapter( + defaultOptions({ + extraHeaders: { + "X-Custom-Header": "custom-value", + "OpenAI-Beta": "realtime=v1", + }, + }) + ); + + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + const headers = (ws.options as Record).headers as Record; + expect(headers["X-Custom-Header"]).toBe("custom-value"); + expect(headers["OpenAI-Beta"]).toBe("realtime=v1"); + expect(headers.Authorization).toBe("Bearer sk-test-key-123"); + + ws.emit("open"); + ws.emit("message", makeCompletedEvent()); + await turnPromise; + }); + + // ========================================================================= + // Event Collection + // ========================================================================= + + it("collects delta events and returns them in events array", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + + ws.emit("message", makeDeltaEvent("Hello")); + ws.emit("message", makeDeltaEvent(" world")); + ws.emit("message", makeDeltaEvent("!")); + + ws.emit("message", makeCompletedEvent()); + + const result = await turnPromise; + expect(result.events).toHaveLength(4); // 3 deltas + 1 terminal + expect(result.events[0].type).toBe("response.output_text.delta"); + expect(result.events[1].type).toBe("response.output_text.delta"); + expect(result.events[2].type).toBe("response.output_text.delta"); + expect(result.events[3].type).toBe("response.completed"); + }); + + // ========================================================================= + // Terminal Events + // ========================================================================= + + it("resolves on response.completed with usage extraction", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + ws.emit("message", makeCompletedEvent()); + + const result = await turnPromise; + expect(result.completed).toBe(true); + expect(result.terminalType).toBe("response.completed"); + expect(result.usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }); + expect(result.model).toBe("gpt-4o"); + expect(result.serviceTier).toBe("default"); + expect(result.promptCacheKey).toBe("cache-key-001"); + expect(result.error).toBeUndefined(); + }); + + it("resolves on response.failed without fake success", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + ws.emit("message", makeFailedEvent()); + + const result = await turnPromise; + expect(result.completed).toBe(true); + expect(result.terminalType).toBe("response.failed"); + expect(result.terminalEvent?.response.status).toBe("failed"); + }); + + it("resolves on response.incomplete terminal event", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + ws.emit("message", makeIncompleteEvent()); + + const result = await turnPromise; + expect(result.completed).toBe(true); + expect(result.terminalType).toBe("response.incomplete"); + }); + + // ========================================================================= + // Timeouts + // ========================================================================= + + it("fires handshake timeout when server does not respond", async () => { + const adapter = new OutboundWsAdapter(defaultOptions({ handshakeTimeoutMs: 500 })); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + // Do NOT emit "open" - let handshake timeout fire + vi.advanceTimersByTime(500); + + const result = await turnPromise; + expect(result.completed).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toContain("Handshake timeout"); + expect((result.error as Error).message).toContain("500"); + }); + + it("fires idle timeout when no events received after open", async () => { + const adapter = new OutboundWsAdapter(defaultOptions({ idleTimeoutMs: 1000 })); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + + // Advance past idle timeout + vi.advanceTimersByTime(1000); + + const result = await turnPromise; + expect(result.completed).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toContain("Idle timeout"); + }); + + it("allows caller to configure longer idle timeout for flex tier", async () => { + // The adapter itself does not auto-detect flex; caller sets the timeout + const adapter = new OutboundWsAdapter(defaultOptions({ idleTimeoutMs: 300_000 })); + const turnPromise = adapter.executeTurn({ + model: "gpt-4o", + input: [], + service_tier: "flex", + }); + + const ws = getMockInstance()!; + ws.emit("open"); + + // 60s would trigger default 60s timeout, but we configured 300s + vi.advanceTimersByTime(60_000); + + // Emit a delta to prove adapter is still listening + ws.emit("message", makeDeltaEvent("still going")); + + // Complete the turn + ws.emit("message", makeCompletedEvent()); + + const result = await turnPromise; + expect(result.completed).toBe(true); + expect(result.events).toHaveLength(2); // delta + completed + }); + + // ========================================================================= + // Error Handling + // ========================================================================= + + it("resolves with parsed error on server error frame", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + ws.emit("message", makeErrorFrame()); + + const result = await turnPromise; + expect(result.completed).toBe(false); + expect(result.error).toBeDefined(); + // Server error frame is parsed as ServerErrorFrame (not Error instance) + if (!(result.error instanceof Error)) { + expect(result.error!.error.message).toBe("Model not found"); + expect(result.error!.error.type).toBe("invalid_request_error"); + } + }); + + it("resolves with error on unexpected WebSocket close", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + + // Server closes unexpectedly + ws.readyState = 3; + ws.emit("close", 1006, Buffer.from("abnormal closure")); + + const result = await turnPromise; + expect(result.completed).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toContain("WebSocket closed unexpectedly"); + expect((result.error as Error).message).toContain("1006"); + }); + + it("resolves with error on WebSocket error event", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + // Error before open (e.g. DNS failure) + ws.emit("error", new Error("ECONNREFUSED")); + + const result = await turnPromise; + expect(result.completed).toBe(false); + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toBe("ECONNREFUSED"); + }); + + // ========================================================================= + // close() + // ========================================================================= + + it("close() terminates the connection", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + + adapter.close(); + expect(ws.close).toHaveBeenCalledWith(1000); + + // Simulate the close event that follows + ws.emit("close", 1000, Buffer.from("")); + + const result = await turnPromise; + expect(result.completed).toBe(false); + }); + + // ========================================================================= + // Handshake Latency + // ========================================================================= + + it("records handshakeMs correctly", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + + // Advance 150ms before open fires + vi.advanceTimersByTime(150); + ws.emit("open"); + + ws.emit("message", makeCompletedEvent()); + + const result = await turnPromise; + expect(result.handshakeMs).toBeGreaterThanOrEqual(150); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + + it("ignores non-JSON messages without breaking", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + + // Non-JSON message + ws.emit("message", "not json at all"); + + // Terminal event still works + ws.emit("message", makeCompletedEvent()); + + const result = await turnPromise; + expect(result.completed).toBe(true); + // Non-JSON message should NOT appear in events + expect(result.events).toHaveLength(1); + }); + + it("does not resolve twice on error + close sequence", async () => { + const adapter = new OutboundWsAdapter(defaultOptions()); + const turnPromise = adapter.executeTurn({ model: "gpt-4o", input: [] }); + + const ws = getMockInstance()!; + ws.emit("open"); + + // Error frame followed by close + ws.emit("message", makeErrorFrame()); + ws.emit("close", 1000, Buffer.from("")); + + const result = await turnPromise; + // Should only resolve once with the error frame result + expect(result.completed).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/tests/unit/ws/session-continuity.test.ts b/tests/unit/ws/session-continuity.test.ts new file mode 100644 index 000000000..8c873dc9a --- /dev/null +++ b/tests/unit/ws/session-continuity.test.ts @@ -0,0 +1,393 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks (must be declared before imports that depend on them) +// --------------------------------------------------------------------------- + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +const mockUpdateSessionWithCodexCacheKey = vi.fn(); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionWithCodexCacheKey: (...args: unknown[]) => + mockUpdateSessionWithCodexCacheKey(...args), + }, +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { + createWsTurnContext, + updateSessionFromTerminal, + classifyDisconnect, + isNeutralFallback, + type WsTurnContext, + type TurnPhase, + type DisconnectClassification, +} from "@/app/v1/_lib/ws/session-continuity"; +import type { SettlementResult } from "@/app/v1/_lib/ws/event-bridge"; +import type { TurnMeta, WsAuthContext } from "@/app/v1/_lib/ws/ingress-handler"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function createMockAuth(overrides?: Partial): WsAuthContext { + return { + user: { id: 42, name: "test-user", isEnabled: true } as any, + key: { id: 7, name: "test-key" } as any, + apiKey: "sk-test-key-12345", + ...overrides, + }; +} + +function createMockTurnMeta(overrides?: Partial): TurnMeta { + return { + model: "gpt-4o", + serviceTier: "default", + previousResponseId: undefined, + frame: { + type: "response.create" as const, + response: { model: "gpt-4o" }, + } as any, + ...overrides, + }; +} + +function createMockSettlement(overrides?: Partial): SettlementResult { + return { + status: "completed", + eventCount: 10, + durationMs: 1500, + model: "gpt-4o", + serviceTier: "default", + promptCacheKey: undefined, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("session-continuity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ========================================================================= + // createWsTurnContext + // ========================================================================= + + describe("createWsTurnContext", () => { + it("creates proper context from auth and turn meta", () => { + const auth = createMockAuth(); + const turnMeta = createMockTurnMeta({ model: "o3-pro" }); + + const ctx = createWsTurnContext(auth, turnMeta); + + expect(ctx.model).toBe("o3-pro"); + expect(ctx.previousResponseId).toBeUndefined(); + expect(ctx.promptCacheKey).toBeUndefined(); + expect(ctx.transport).toBe("websocket"); + expect(ctx.keyId).toBe(7); + expect(ctx.userId).toBe(42); + expect(ctx.startedAt).toBeGreaterThan(0); + }); + + it("preserves previousResponseId from turn meta", () => { + const auth = createMockAuth(); + const turnMeta = createMockTurnMeta({ + previousResponseId: "resp_abc123def456789012345", + }); + + const ctx = createWsTurnContext(auth, turnMeta); + + expect(ctx.previousResponseId).toBe("resp_abc123def456789012345"); + }); + + it("extracts keyId and userId from auth context", () => { + const auth = createMockAuth({ + user: { id: 99, name: "admin", isEnabled: true } as any, + key: { id: 15, name: "admin-key" } as any, + }); + const turnMeta = createMockTurnMeta(); + + const ctx = createWsTurnContext(auth, turnMeta); + + expect(ctx.keyId).toBe(15); + expect(ctx.userId).toBe(99); + }); + + it("always sets transport to websocket", () => { + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + expect(ctx.transport).toBe("websocket"); + }); + }); + + // ========================================================================= + // updateSessionFromTerminal + // ========================================================================= + + describe("updateSessionFromTerminal", () => { + it("extracts prompt_cache_key from settlement and updates session binding", async () => { + mockUpdateSessionWithCodexCacheKey.mockResolvedValue({ + sessionId: "codex_cache-key-001", + updated: true, + }); + + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + const settlement = createMockSettlement({ + promptCacheKey: "cache-key-001", + }); + + const result = await updateSessionFromTerminal(ctx, settlement, "session-123", 5); + + expect(result.sessionUpdated).toBe(true); + expect(result.turnContext.promptCacheKey).toBe("cache-key-001"); + expect(mockUpdateSessionWithCodexCacheKey).toHaveBeenCalledWith( + "session-123", + "cache-key-001", + 5 + ); + }); + + it("returns sessionUpdated=false when no prompt_cache_key in settlement", async () => { + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + const settlement = createMockSettlement({ promptCacheKey: undefined }); + + const result = await updateSessionFromTerminal(ctx, settlement, "session-123", 5); + + expect(result.sessionUpdated).toBe(false); + expect(result.turnContext.promptCacheKey).toBeUndefined(); + expect(mockUpdateSessionWithCodexCacheKey).not.toHaveBeenCalled(); + }); + + it("populates turnContext.promptCacheKey even when sessionId is null", async () => { + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + const settlement = createMockSettlement({ + promptCacheKey: "cache-key-002", + }); + + const result = await updateSessionFromTerminal(ctx, settlement, null, 5); + + expect(result.sessionUpdated).toBe(false); + expect(result.turnContext.promptCacheKey).toBe("cache-key-002"); + expect(mockUpdateSessionWithCodexCacheKey).not.toHaveBeenCalled(); + }); + + it("populates turnContext.promptCacheKey even when providerId is null", async () => { + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + const settlement = createMockSettlement({ + promptCacheKey: "cache-key-003", + }); + + const result = await updateSessionFromTerminal(ctx, settlement, "session-123", null); + + expect(result.sessionUpdated).toBe(false); + expect(result.turnContext.promptCacheKey).toBe("cache-key-003"); + expect(mockUpdateSessionWithCodexCacheKey).not.toHaveBeenCalled(); + }); + + it("handles SessionManager errors gracefully without throwing", async () => { + mockUpdateSessionWithCodexCacheKey.mockRejectedValue(new Error("Redis connection failed")); + + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + const settlement = createMockSettlement({ + promptCacheKey: "cache-key-004", + }); + + const result = await updateSessionFromTerminal(ctx, settlement, "session-123", 5); + + expect(result.sessionUpdated).toBe(false); + expect(result.turnContext.promptCacheKey).toBe("cache-key-004"); + }); + + it("returns sessionUpdated=false when SessionManager reports no update", async () => { + mockUpdateSessionWithCodexCacheKey.mockResolvedValue({ + sessionId: "codex_existing-key", + updated: false, + }); + + const ctx = createWsTurnContext(createMockAuth(), createMockTurnMeta()); + const settlement = createMockSettlement({ + promptCacheKey: "existing-key", + }); + + const result = await updateSessionFromTerminal(ctx, settlement, "session-123", 5); + + expect(result.sessionUpdated).toBe(false); + expect(result.turnContext.promptCacheKey).toBe("existing-key"); + }); + }); + + // ========================================================================= + // classifyDisconnect + // ========================================================================= + + describe("classifyDisconnect", () => { + it('returns "retryable" for setup phase (pre-stream) errors', () => { + expect(classifyDisconnect("setup")).toBe("retryable"); + }); + + it('returns "retryable" for setup phase with generic transport error', () => { + expect(classifyDisconnect("setup", "ECONNREFUSED")).toBe("retryable"); + }); + + it('returns "terminal" for streaming phase (mid-stream breaks)', () => { + expect(classifyDisconnect("streaming")).toBe("terminal"); + }); + + it('returns "terminal" for settled phase', () => { + expect(classifyDisconnect("settled")).toBe("terminal"); + }); + + it('returns "terminal" for previous_response_not_found regardless of phase', () => { + // This error must be surfaced as explicit protocol error, never silently retried + expect(classifyDisconnect("setup", "previous_response_not_found")).toBe("terminal"); + expect(classifyDisconnect("streaming", "previous_response_not_found")).toBe("terminal"); + expect(classifyDisconnect("settled", "previous_response_not_found")).toBe("terminal"); + }); + + it('returns "terminal" for websocket_connection_limit_reached regardless of phase', () => { + // This error must be surfaced as explicit protocol error + expect(classifyDisconnect("setup", "websocket_connection_limit_reached")).toBe("terminal"); + expect(classifyDisconnect("streaming", "websocket_connection_limit_reached")).toBe( + "terminal" + ); + expect(classifyDisconnect("settled", "websocket_connection_limit_reached")).toBe("terminal"); + }); + + it("does NOT silently retry mid-stream disconnects (no hidden HTTP replay)", () => { + // Critical invariant: once upstream has started streaming events, + // a disconnect MUST fail the turn explicitly. + const midStreamErrors = ["ECONNRESET", "ETIMEDOUT", "EPIPE", undefined]; + for (const code of midStreamErrors) { + const result = classifyDisconnect("streaming", code); + expect(result).toBe("terminal"); + } + }); + + it("handles undefined errorCode gracefully", () => { + expect(classifyDisconnect("setup", undefined)).toBe("retryable"); + expect(classifyDisconnect("streaming", undefined)).toBe("terminal"); + }); + }); + + // ========================================================================= + // isNeutralFallback + // ========================================================================= + + describe("isNeutralFallback", () => { + // --- Transport/setup failures: should be neutral --- + + it("identifies ECONNREFUSED as neutral fallback", () => { + expect(isNeutralFallback(new Error("connect ECONNREFUSED 127.0.0.1:443"))).toBe(true); + }); + + it("identifies ECONNRESET as neutral fallback", () => { + expect(isNeutralFallback(new Error("read ECONNRESET"))).toBe(true); + }); + + it("identifies ETIMEDOUT as neutral fallback", () => { + expect(isNeutralFallback(new Error("connect ETIMEDOUT"))).toBe(true); + }); + + it("identifies EHOSTUNREACH as neutral fallback", () => { + expect(isNeutralFallback(new Error("connect EHOSTUNREACH"))).toBe(true); + }); + + it("identifies ENOTFOUND as neutral fallback", () => { + expect(isNeutralFallback(new Error("getaddrinfo ENOTFOUND api.example.com"))).toBe(true); + }); + + it("identifies handshake timeout as neutral fallback", () => { + expect(isNeutralFallback(new Error("WebSocket handshake timeout"))).toBe(true); + }); + + it("identifies WebSocket upgrade rejection as neutral fallback", () => { + expect(isNeutralFallback(new Error("WebSocket upgrade rejected"))).toBe(true); + }); + + it("identifies socket hang up as neutral fallback", () => { + expect(isNeutralFallback(new Error("socket hang up"))).toBe(true); + }); + + // --- Explicit protocol errors: NOT neutral --- + + it("does NOT identify previous_response_not_found as neutral (by code)", () => { + expect(isNeutralFallback({ code: "previous_response_not_found", message: "Not found" })).toBe( + false + ); + }); + + it("does NOT identify previous_response_not_found as neutral (by type)", () => { + expect( + isNeutralFallback({ + type: "previous_response_not_found", + message: "Previous response not found", + }) + ).toBe(false); + }); + + it("does NOT identify previous_response_not_found as neutral (by message)", () => { + expect(isNeutralFallback(new Error("previous_response_not_found: resp_abc not found"))).toBe( + false + ); + }); + + it("does NOT identify websocket_connection_limit_reached as neutral (by code)", () => { + expect( + isNeutralFallback({ + code: "websocket_connection_limit_reached", + message: "Connection limit reached", + }) + ).toBe(false); + }); + + it("does NOT identify websocket_connection_limit_reached as neutral (by type)", () => { + expect( + isNeutralFallback({ + type: "websocket_connection_limit_reached", + message: "Limit reached", + }) + ).toBe(false); + }); + + it("does NOT identify websocket_connection_limit_reached as neutral (by message)", () => { + // Even though message contains "websocket", the explicit error name takes precedence + expect( + isNeutralFallback(new Error("websocket_connection_limit_reached: too many connections")) + ).toBe(false); + }); + + // --- Generic API errors: NOT neutral --- + + it("does NOT identify rate limit errors as neutral", () => { + expect(isNeutralFallback(new Error("Rate limit exceeded"))).toBe(false); + }); + + it("does NOT identify internal server errors as neutral", () => { + expect(isNeutralFallback(new Error("Internal server error"))).toBe(false); + }); + + it("does NOT identify authentication errors as neutral", () => { + expect(isNeutralFallback(new Error("Invalid API key"))).toBe(false); + }); + + it("does NOT identify model errors as neutral", () => { + expect(isNeutralFallback(new Error("Model not found: o3-pro"))).toBe(false); + }); + }); +}); From f768c63ecd252a021c9bc0bb107e523894a61208 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 02:15:10 +0800 Subject: [PATCH 3/4] feat(ws): billing parity, provider WS testing, and full regression coverage Wave 3 of OpenAI Responses WebSocket support: - Billing parity: WS terminal events feed through same cost/usage pipeline as HTTP (settleWsTurnBilling, wsUsageToMetrics, redactWsEventPayload) - Provider testing: WS probe backend (probeProviderWebSocket) reports transport, handshake latency, event count, fallback reason - Provider testing UI: WsTestStatus component with transport badge, metrics display, and 5-language i18n - Trace metadata builder for Langfuse with WS transport fields --- .../en/settings/providers/form/apiTest.json | 11 +- .../ja/settings/providers/form/apiTest.json | 11 +- .../ru/settings/providers/form/apiTest.json | 11 +- .../settings/providers/form/apiTest.json | 9 + .../settings/providers/form/apiTest.json | 11 +- .../_components/forms/ws-test-status.tsx | 95 ++++ src/app/v1/_lib/ws/billing-parity.ts | 309 ++++++++++++ src/lib/provider-testing/ws-probe.ts | 203 ++++++++ src/lib/provider-testing/ws-types.ts | 14 + tests/unit/provider-testing/ws-probe.test.ts | 452 ++++++++++++++++++ .../provider-testing/ws-test-status.test.tsx | 209 ++++++++ tests/unit/ws/billing-parity.test.ts | 410 ++++++++++++++++ 12 files changed, 1741 insertions(+), 4 deletions(-) create mode 100644 src/app/[locale]/settings/providers/_components/forms/ws-test-status.tsx create mode 100644 src/app/v1/_lib/ws/billing-parity.ts create mode 100644 src/lib/provider-testing/ws-probe.ts create mode 100644 src/lib/provider-testing/ws-types.ts create mode 100644 tests/unit/provider-testing/ws-probe.test.ts create mode 100644 tests/unit/provider-testing/ws-test-status.test.tsx create mode 100644 tests/unit/ws/billing-parity.test.ts diff --git a/messages/en/settings/providers/form/apiTest.json b/messages/en/settings/providers/form/apiTest.json index 58649d0c2..7db6ab60e 100644 --- a/messages/en/settings/providers/form/apiTest.json +++ b/messages/en/settings/providers/form/apiTest.json @@ -153,5 +153,14 @@ "truncatedPreview": "Showing first {length} characters, copy to see full content", "unknown": "Unknown", "usage": "Token usage", - "viewDetails": "View Details" + "viewDetails": "View Details", + "ws": { + "eventCount": "Events", + "fallback": "HTTP Fallback", + "fallbackReason": "Fallback Reason", + "handshakeMs": "Handshake", + "status": "WebSocket Status", + "supported": "Supported", + "unsupported": "Unsupported" + } } diff --git a/messages/ja/settings/providers/form/apiTest.json b/messages/ja/settings/providers/form/apiTest.json index b31f0b463..f34cf25a5 100644 --- a/messages/ja/settings/providers/form/apiTest.json +++ b/messages/ja/settings/providers/form/apiTest.json @@ -153,5 +153,14 @@ "truncatedPreview": "先頭 {length} 文字を表示、全文はコピーして確認", "unknown": "不明", "usage": "トークン使用量", - "viewDetails": "詳細を見る" + "viewDetails": "詳細を見る", + "ws": { + "eventCount": "イベント数", + "fallback": "HTTP フォールバック", + "fallbackReason": "フォールバック理由", + "handshakeMs": "ハンドシェイク", + "status": "WebSocket ステータス", + "supported": "サポート済み", + "unsupported": "非対応" + } } diff --git a/messages/ru/settings/providers/form/apiTest.json b/messages/ru/settings/providers/form/apiTest.json index 94382567a..548d8d2e5 100644 --- a/messages/ru/settings/providers/form/apiTest.json +++ b/messages/ru/settings/providers/form/apiTest.json @@ -153,5 +153,14 @@ "truncatedPreview": "Показаны первые {length} символов, скопируйте для просмотра полного текста", "unknown": "Неизвестно", "usage": "Использование токенов", - "viewDetails": "Подробнее" + "viewDetails": "Подробнее", + "ws": { + "eventCount": "События", + "fallback": "HTTP резерв", + "fallbackReason": "Причина резерва", + "handshakeMs": "Рукопожатие", + "status": "Статус WebSocket", + "supported": "Поддерживается", + "unsupported": "Не поддерживается" + } } diff --git a/messages/zh-CN/settings/providers/form/apiTest.json b/messages/zh-CN/settings/providers/form/apiTest.json index fc43eee41..c3ac7fa60 100644 --- a/messages/zh-CN/settings/providers/form/apiTest.json +++ b/messages/zh-CN/settings/providers/form/apiTest.json @@ -153,5 +153,14 @@ "contentCheck": "内容验证" }, "judgment": "判定" + }, + "ws": { + "eventCount": "事件数", + "fallback": "HTTP 回退", + "fallbackReason": "回退原因", + "handshakeMs": "握手延迟", + "status": "WebSocket 状态", + "supported": "已支持", + "unsupported": "不支持" } } diff --git a/messages/zh-TW/settings/providers/form/apiTest.json b/messages/zh-TW/settings/providers/form/apiTest.json index 0724624dc..b03461d8e 100644 --- a/messages/zh-TW/settings/providers/form/apiTest.json +++ b/messages/zh-TW/settings/providers/form/apiTest.json @@ -153,5 +153,14 @@ "truncatedPreview": "顯示前 {length} 個字元,複製可查看完整內容", "unknown": "不明", "usage": "Token 使用量", - "viewDetails": "檢視詳情" + "viewDetails": "檢視詳情", + "ws": { + "eventCount": "事件數", + "fallback": "HTTP 回退", + "fallbackReason": "回退原因", + "handshakeMs": "握手延遲", + "status": "WebSocket 狀態", + "supported": "已支援", + "unsupported": "不支援" + } } diff --git a/src/app/[locale]/settings/providers/_components/forms/ws-test-status.tsx b/src/app/[locale]/settings/providers/_components/forms/ws-test-status.tsx new file mode 100644 index 000000000..537c9fea4 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/ws-test-status.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import type { WsTestResultFields } from "@/lib/provider-testing/ws-types"; + +interface WsTestStatusProps { + result: WsTestResultFields; +} + +/** + * Inline WebSocket status section for the provider test result card. + * + * Renders transport badge (WS / HTTP Fallback / Unsupported), + * handshake latency, event count, and fallback reason. + * + * Returns null when no WS-related fields are present. + */ +export function WsTestStatus({ result }: WsTestStatusProps) { + const t = useTranslations("settings.providers.form.apiTest"); + + // Nothing to show if no WS data at all + const hasWsData = result.wsSupported !== undefined || result.wsTransport !== undefined; + if (!hasWsData) return null; + + return ( +
+ {/* Header: title + transport badge */} +
+ {t("ws.status")} + +
+ + {/* Metrics row */} + {(result.wsHandshakeMs !== undefined || result.wsEventCount !== undefined) && ( +
+ {result.wsHandshakeMs !== undefined && ( +
+ {t("ws.handshakeMs")}:{" "} + {result.wsHandshakeMs}ms +
+ )} + {result.wsEventCount !== undefined && ( +
+ {t("ws.eventCount")}:{" "} + {result.wsEventCount} +
+ )} +
+ )} + + {/* Fallback reason */} + {result.wsFallbackReason && ( +
+ {t("ws.fallbackReason")}:{" "} + {result.wsFallbackReason} +
+ )} +
+ ); +} + +/** + * Transport badge with color-coded variant. + */ +function TransportBadge({ + transport, + t, +}: { + transport: WsTestResultFields["wsTransport"]; + t: ReturnType; +}) { + switch (transport) { + case "websocket": + return ( + + {t("ws.supported")} + + ); + case "http_fallback": + return ( + + {t("ws.fallback")} + + ); + case "unsupported": + return ( + + {t("ws.unsupported")} + + ); + default: + return null; + } +} diff --git a/src/app/v1/_lib/ws/billing-parity.ts b/src/app/v1/_lib/ws/billing-parity.ts new file mode 100644 index 000000000..ddb97cb01 --- /dev/null +++ b/src/app/v1/_lib/ws/billing-parity.ts @@ -0,0 +1,309 @@ +/** + * WS Billing Parity Module + * + * Thin adapter that feeds WS terminal payloads through the SAME + * billing/logging sinks as the HTTP proxy path, ensuring cost + * calculation, trace metadata, and content redaction remain + * consistent across transport types. + * + * This module does NOT modify existing files. It provides adapter + * functions that translate WS types to the shapes expected by the + * existing billing pipeline (calculateRequestCost, CostBreakdown, + * UsageMetrics, REDACTED_MARKER). + */ + +import type { UsageMetrics } from "@/app/v1/_lib/proxy/response-handler"; +import { + type CostBreakdown, + calculateRequestCost, + calculateRequestCostBreakdown, +} from "@/lib/utils/cost-calculation"; +import { REDACTED_MARKER } from "@/lib/utils/message-redaction"; +import type { ResponseUsage } from "@/lib/ws/frames"; +import type { ModelPriceData } from "@/types/model-price"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface WsBillingParams { + /** Usage from terminal event */ + usage?: ResponseUsage; + /** Model from terminal response */ + model?: string; + /** Actual service tier from terminal response */ + serviceTier?: string; + /** Requested service tier from client request */ + requestedServiceTier?: string; + /** Price data for cost calculation (no cost if absent) */ + priceData?: ModelPriceData; + /** Provider cost multiplier (default: 1.0) */ + costMultiplier?: number; + /** Whether 1M context was applied */ + context1mApplied?: boolean; +} + +export interface WsBillingResult { + /** Normalized usage metrics (null if no usage) */ + usageMetrics: UsageMetrics | null; + /** Individual token counts extracted from usage */ + inputTokens?: number; + outputTokens?: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + /** Whether priority service tier pricing should apply */ + priorityServiceTierApplied: boolean; + /** Computed cost in USD (undefined if no priceData or no usage) */ + costUsd?: string; + /** Cost breakdown by category (undefined if no priceData or no usage) */ + costBreakdown?: CostBreakdown; +} + +export interface WsTraceParams { + /** Handshake latency in ms */ + handshakeMs?: number; + /** Total events relayed */ + eventCount: number; + /** Terminal event type (e.g. "response.completed") */ + terminalType?: string; + /** Model from terminal response */ + model?: string; + /** Service tier from terminal response */ + serviceTier?: string; + /** Total turn duration in ms */ + durationMs: number; + /** HTTP-equivalent status code */ + statusCode?: number; + /** Error message if failed */ + errorMessage?: string; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Redact a single output item using the same rules as redactCodexOutput + * in message-redaction.ts, plus encrypted_content redaction. + */ +function redactOutputItem(item: Record): Record { + const redacted = { ...item }; + const itemType = redacted.type as string; + + // Redact message content[].text + if (itemType === "message" && "content" in redacted && Array.isArray(redacted.content)) { + redacted.content = (redacted.content as unknown[]).map((c) => { + if (!isPlainObject(c)) return c; + const rc = { ...c }; + if ("text" in rc && typeof rc.text === "string") { + rc.text = REDACTED_MARKER; + } + return rc; + }); + } + + // Redact reasoning summary[].text + if (itemType === "reasoning" && "summary" in redacted && Array.isArray(redacted.summary)) { + redacted.summary = (redacted.summary as unknown[]).map((s) => { + if (!isPlainObject(s)) return s; + const rs = { ...s }; + if ("text" in rs && typeof rs.text === "string") { + rs.text = REDACTED_MARKER; + } + return rs; + }); + } + + // Redact encrypted_content (present on reasoning items) + if ("encrypted_content" in redacted && typeof redacted.encrypted_content === "string") { + redacted.encrypted_content = REDACTED_MARKER; + } + + // Redact function_call arguments + if (itemType === "function_call" && "arguments" in redacted) { + redacted.arguments = REDACTED_MARKER; + } + + return redacted; +} + +// --------------------------------------------------------------------------- +// wsUsageToMetrics +// --------------------------------------------------------------------------- + +/** + * Convert WS ResponseUsage (with potential passthrough cache fields) + * to the canonical UsageMetrics type used by the billing pipeline. + * + * ResponseUsage schema uses .passthrough(), so cache fields may exist + * as extra properties not visible at the TypeScript level. + */ +export function wsUsageToMetrics(usage?: ResponseUsage): UsageMetrics | null { + if (!usage) return null; + + // Access passthrough fields via Record cast + const raw = usage as Record; + + return { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_creation_input_tokens: + typeof raw.cache_creation_input_tokens === "number" + ? raw.cache_creation_input_tokens + : undefined, + cache_read_input_tokens: + typeof raw.cache_read_input_tokens === "number" ? raw.cache_read_input_tokens : undefined, + // WS terminal payloads do not include 5m/1h split or cache_ttl; + // leaving these undefined causes downstream pricing to fall back + // to the unified cache_creation_input_tokens path. + }; +} + +// --------------------------------------------------------------------------- +// settleWsTurnBilling +// --------------------------------------------------------------------------- + +/** + * Settle billing for a single WS turn using the same cost calculation + * logic as the HTTP proxy path. + * + * Mirrors the flow in response-handler.ts: + * 1. Extract usage metrics from ResponseUsage + * 2. Determine priority service tier (actual from terminal > requested) + * 3. Calculate cost via calculateRequestCost / calculateRequestCostBreakdown + */ +export function settleWsTurnBilling(params: WsBillingParams): WsBillingResult { + const { + usage, + serviceTier, + requestedServiceTier, + priceData, + costMultiplier = 1.0, + context1mApplied = false, + } = params; + + const usageMetrics = wsUsageToMetrics(usage); + + // Determine priority service tier: actual from terminal takes precedence, + // fall back to requested tier. Mirrors isPriorityServiceTierApplied in + // response-handler.ts. + const priorityServiceTierApplied = + serviceTier != null ? serviceTier === "priority" : requestedServiceTier === "priority"; + + const result: WsBillingResult = { + usageMetrics, + inputTokens: usageMetrics?.input_tokens, + outputTokens: usageMetrics?.output_tokens, + cacheCreationInputTokens: usageMetrics?.cache_creation_input_tokens, + cacheReadInputTokens: usageMetrics?.cache_read_input_tokens, + priorityServiceTierApplied, + }; + + // Calculate cost only when both usage and pricing data are available + if (usageMetrics && priceData) { + const cost = calculateRequestCost( + usageMetrics, + priceData, + costMultiplier, + context1mApplied, + priorityServiceTierApplied + ); + + if (cost.gt(0)) { + result.costUsd = cost.toString(); + } + + result.costBreakdown = calculateRequestCostBreakdown( + usageMetrics, + priceData, + context1mApplied, + priorityServiceTierApplied + ); + } + + return result; +} + +// --------------------------------------------------------------------------- +// buildWsTraceMetadata +// --------------------------------------------------------------------------- + +/** + * Build trace metadata for Langfuse/logging that includes WS transport info. + * Structured to merge into the existing generation metadata record used by + * traceProxyRequest(). + */ +export function buildWsTraceMetadata(params: WsTraceParams): Record { + return { + transport: "websocket", + handshakeMs: params.handshakeMs, + eventCount: params.eventCount, + terminalType: params.terminalType, + model: params.model, + serviceTier: params.serviceTier, + durationMs: params.durationMs, + statusCode: params.statusCode, + errorMessage: params.errorMessage, + }; +} + +// --------------------------------------------------------------------------- +// redactWsEventPayload +// --------------------------------------------------------------------------- + +/** Event types whose `delta` field contains sensitive user content. */ +const SENSITIVE_DELTA_TYPES = new Set([ + "response.output_text.delta", + "response.reasoning_summary_text.delta", + "response.function_call_arguments.delta", + "response.content_part.delta", +]); + +/** + * Apply content redaction to a WS event payload, consistent with the + * redaction rules applied to HTTP response bodies (redactCodexOutput). + * + * Handles three event shapes: + * 1. Terminal events with response.output[] (response.completed/failed/incomplete) + * 2. Streaming item events with item field (response.output_item.done) + * 3. Delta events with string delta field (response.output_text.delta, etc.) + * + * Returns a shallow-cloned event with sensitive content replaced by + * REDACTED_MARKER. Does not mutate the original. + */ +export function redactWsEventPayload(event: Record): Record { + const result = { ...event }; + const eventType = typeof result.type === "string" ? result.type : ""; + + // 1. Terminal events: redact response.output[] items + if (isPlainObject(result.response)) { + const response = { ...(result.response as Record) }; + + if ("output" in response && Array.isArray(response.output)) { + response.output = (response.output as unknown[]).map((item) => { + if (!isPlainObject(item)) return item; + return redactOutputItem(item); + }); + } + + result.response = response; + } + + // 2. Streaming item events: redact item content + if ("item" in result && isPlainObject(result.item)) { + result.item = redactOutputItem(result.item as Record); + } + + // 3. Delta events: redact text/reasoning/function_call deltas + if ("delta" in result && typeof result.delta === "string") { + if (SENSITIVE_DELTA_TYPES.has(eventType)) { + result.delta = REDACTED_MARKER; + } + } + + return result; +} diff --git a/src/lib/provider-testing/ws-probe.ts b/src/lib/provider-testing/ws-probe.ts new file mode 100644 index 000000000..7f6f278e0 --- /dev/null +++ b/src/lib/provider-testing/ws-probe.ts @@ -0,0 +1,203 @@ +/** + * WebSocket Provider Probe + * + * Tests whether a provider supports Responses WebSocket transport + * by attempting a minimal response.create turn via OutboundWsAdapter. + * + * Design: + * - Wraps OutboundWsAdapter with probe-appropriate timeouts + * - Builds request payload from cx_base preset (or custom preset) + * - Interprets the turn result into a WsProbeResult + * - Handshake failures are reported as "unsupported", not errors + * - Self-contained: does not modify existing HTTP test paths + */ + +import { + type OutboundAdapterOptions, + type OutboundTurnResult, + OutboundWsAdapter, +} from "@/app/v1/_lib/ws/outbound-adapter"; +import { getPreset, getPresetPayload } from "./presets"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default handshake timeout for probe (ms) */ +const PROBE_HANDSHAKE_TIMEOUT_MS = 10_000; + +/** Default idle timeout for probe (ms) - shorter than production */ +const PROBE_IDLE_TIMEOUT_MS = 30_000; + +/** Default preset for WS probe */ +const DEFAULT_PROBE_PRESET = "cx_base"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Configuration for a WebSocket probe request + */ +export interface WsProbeConfig { + /** Provider base URL (https://) - will be converted to wss:// internally */ + providerUrl: string; + /** API key for Bearer token authentication */ + apiKey: string; + /** Model to test (defaults to preset default model) */ + model?: string; + /** Overall timeout in ms (controls handshake + idle timeouts) */ + timeoutMs?: number; + /** Preset ID for request payload (default: "cx_base") */ + preset?: string; +} + +/** + * Result of a WebSocket probe against a provider. + * + * Extends the existing test result concept with WS-specific fields. + * Designed to be merged into ProviderTestResult by the caller. + */ +export interface WsProbeResult { + /** Whether the provider supports WebSocket transport */ + wsSupported: boolean; + /** Transport classification: what was actually used / detected */ + wsTransport: "websocket" | "http_fallback" | "unsupported"; + /** WebSocket handshake latency in ms (set only if handshake succeeded) */ + wsHandshakeMs?: number; + /** Number of server events received during the turn */ + wsEventCount?: number; + /** Why WS was not usable (set when wsSupported is false or turn failed) */ + wsFallbackReason?: string; + /** Model string from the terminal event response */ + wsTerminalModel?: string; + /** Usage object from the terminal event response */ + wsTerminalUsage?: Record; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Probe a provider's WebSocket support by attempting a minimal response.create turn. + * + * Flow: + * 1. Build a test payload from the preset (defaults to cx_base) + * 2. Create an OutboundWsAdapter with probe-appropriate timeouts + * 3. Execute a single turn via WebSocket + * 4. Interpret the result: + * - handshakeMs present + completed -> wsSupported=true, wsTransport="websocket" + * - handshakeMs present + not completed -> wsSupported=true (WS works, but turn errored) + * - handshakeMs absent -> wsSupported=false, wsTransport="unsupported" + * + * If the adapter throws (unexpected crash), the probe catches it + * and reports unsupported with the error message. + */ +export async function probeProviderWebSocket(config: WsProbeConfig): Promise { + // Resolve preset and model + const presetId = config.preset ?? DEFAULT_PROBE_PRESET; + const presetConfig = getPreset(presetId); + const model = config.model ?? presetConfig?.defaultModel; + + // Build request payload + let payload: Record; + if (presetConfig) { + payload = getPresetPayload(presetId, model); + } else { + // Fallback: minimal payload if preset not found + payload = { model: model ?? "gpt-4o", input: [] }; + } + + // Calculate timeouts from config + const handshakeTimeoutMs = config.timeoutMs + ? Math.min(config.timeoutMs, PROBE_HANDSHAKE_TIMEOUT_MS) + : PROBE_HANDSHAKE_TIMEOUT_MS; + const idleTimeoutMs = config.timeoutMs ?? PROBE_IDLE_TIMEOUT_MS; + + // Create adapter + const adapterOptions: OutboundAdapterOptions = { + providerBaseUrl: config.providerUrl, + apiKey: config.apiKey, + handshakeTimeoutMs, + idleTimeoutMs, + }; + + const adapter = new OutboundWsAdapter(adapterOptions); + + try { + const turnResult = await adapter.executeTurn(payload); + return interpretTurnResult(turnResult); + } catch (error) { + // Unexpected error (adapter.executeTurn is designed to always resolve, + // but we guard against edge cases) + adapter.close(); + return { + wsSupported: false, + wsTransport: "unsupported", + wsFallbackReason: error instanceof Error ? error.message : String(error), + }; + } +} + +// --------------------------------------------------------------------------- +// Internal +// --------------------------------------------------------------------------- + +/** + * Interpret an OutboundTurnResult into a WsProbeResult. + * + * Classification logic: + * - handshakeMs present = handshake succeeded = provider supports WS + * - completed = terminal event received = full success + * - error after handshake = WS works but turn had issues (still wsSupported=true) + * - no handshakeMs = handshake failed = provider does not support WS + */ +function interpretTurnResult(result: OutboundTurnResult): WsProbeResult { + const handshakeSucceeded = result.handshakeMs !== undefined; + + if (handshakeSucceeded && result.completed) { + // Best case: WS handshake + turn completed successfully + return { + wsSupported: true, + wsTransport: "websocket", + wsHandshakeMs: result.handshakeMs, + wsEventCount: result.events.length, + wsTerminalModel: result.model, + wsTerminalUsage: result.usage as Record | undefined, + }; + } + + if (handshakeSucceeded && !result.completed) { + // Handshake succeeded but turn failed (server error frame, idle timeout, etc.) + // Provider supports WS, but something went wrong during the turn + return { + wsSupported: true, + wsTransport: "websocket", + wsHandshakeMs: result.handshakeMs, + wsEventCount: result.events.length, + wsFallbackReason: formatError(result.error), + }; + } + + // Handshake never completed - provider does not support WS + return { + wsSupported: false, + wsTransport: "unsupported", + wsFallbackReason: formatError(result.error), + }; +} + +/** + * Format an error from OutboundTurnResult into a human-readable string. + */ +function formatError(error: OutboundTurnResult["error"]): string { + if (!error) return "Unknown error"; + if (error instanceof Error) return error.message; + // ServerErrorFrame shape: { error: { type, message, ... } } + if ("error" in error && typeof error.error === "object" && error.error !== null) { + const serverErr = error.error as { message?: string; type?: string }; + return serverErr.message ?? serverErr.type ?? JSON.stringify(error); + } + return JSON.stringify(error); +} diff --git a/src/lib/provider-testing/ws-types.ts b/src/lib/provider-testing/ws-types.ts new file mode 100644 index 000000000..f2eae5753 --- /dev/null +++ b/src/lib/provider-testing/ws-types.ts @@ -0,0 +1,14 @@ +/** + * WebSocket test result fields for provider testing UI. + * + * Designed to be composed into the existing test result data structure + * without modifying the base ProviderTestResult type. + */ +export interface WsTestResultFields { + wsSupported?: boolean; + wsTransport?: "websocket" | "http_fallback" | "unsupported"; + wsHandshakeMs?: number; + wsEventCount?: number; + wsFallbackReason?: string; + wsTerminalModel?: string; +} diff --git a/tests/unit/provider-testing/ws-probe.test.ts b/tests/unit/provider-testing/ws-probe.test.ts new file mode 100644 index 000000000..a3434000b --- /dev/null +++ b/tests/unit/provider-testing/ws-probe.test.ts @@ -0,0 +1,452 @@ +/** + * WebSocket Provider Probe Tests + * + * Tests probeProviderWebSocket which wraps OutboundWsAdapter + * to test whether a provider supports Responses WebSocket mode. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoisted mock state (survives vitest mockReset) +// --------------------------------------------------------------------------- + +const { getLastAdapter, setLastAdapter, resetAdapter, getCtorArgs, resetCtorArgs } = vi.hoisted( + () => { + type MockAdapter = { + executeTurn: ReturnType; + close: ReturnType; + }; + let adapter: MockAdapter | null = null; + let ctorArgs: unknown[] = []; + + return { + getLastAdapter: (): MockAdapter | null => adapter, + setLastAdapter: (a: MockAdapter) => { + adapter = a; + }, + resetAdapter: () => { + adapter = { + executeTurn: vi.fn(), + close: vi.fn(), + }; + }, + getCtorArgs: () => ctorArgs, + resetCtorArgs: () => { + ctorArgs = []; + }, + }; + } +); + +// --------------------------------------------------------------------------- +// Mock: OutboundWsAdapter (class-based, resilient to mockReset) +// --------------------------------------------------------------------------- + +vi.mock("@/app/v1/_lib/ws/outbound-adapter", () => { + class MockOutboundWsAdapter { + executeTurn: ReturnType; + close: ReturnType; + + constructor(options: unknown) { + getCtorArgs().push(options); + const mock = getLastAdapter()!; + this.executeTurn = mock.executeTurn; + this.close = mock.close; + setLastAdapter(mock); + } + } + + return { OutboundWsAdapter: MockOutboundWsAdapter }; +}); + +// --------------------------------------------------------------------------- +// Mock: transport-classifier (has "server-only" import) +// --------------------------------------------------------------------------- + +vi.mock("@/app/v1/_lib/proxy/transport-classifier", () => ({ + toWebSocketUrl: (url: string) => + `${url.replace("https://", "wss://").replace(/\/$/, "")}/v1/responses`, +})); + +// --------------------------------------------------------------------------- +// Mock: logger +// --------------------------------------------------------------------------- + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Import SUT (after all mocks) +// --------------------------------------------------------------------------- + +import { + probeProviderWebSocket, + type WsProbeConfig, + type WsProbeResult, +} from "@/lib/provider-testing/ws-probe"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function defaultConfig(overrides?: Partial): WsProbeConfig { + return { + providerUrl: "https://api.openai.com", + apiKey: "sk-test-123", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("probeProviderWebSocket", () => { + beforeEach(() => { + resetAdapter(); + resetCtorArgs(); + }); + + // ========================================================================= + // 1. Success case + // ========================================================================= + + it("reports success when WS handshake and terminal event succeed", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 42, + events: [ + { type: "response.output_text.delta", data: {} }, + { type: "response.completed", data: {} }, + ], + model: "gpt-4o", + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsSupported).toBe(true); + expect(result.wsTransport).toBe("websocket"); + expect(result.wsHandshakeMs).toBe(42); + expect(result.wsEventCount).toBe(2); + expect(result.wsTerminalModel).toBe("gpt-4o"); + expect(result.wsTerminalUsage).toEqual({ + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }); + }); + + // ========================================================================= + // 2. Handshake rejected (non-101) + // ========================================================================= + + it("reports 'unsupported' when WS handshake is rejected (non-101 response)", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: false, + events: [], + // No handshakeMs -> handshake never completed + error: new Error("Unexpected server response: 403"), + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsSupported).toBe(false); + expect(result.wsTransport).toBe("unsupported"); + expect(result.wsFallbackReason).toContain("403"); + }); + + // ========================================================================= + // 3. Handshake timeout + // ========================================================================= + + it("reports 'unsupported' when WS handshake times out", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: false, + events: [], + // No handshakeMs -> handshake never completed + error: new Error("Handshake timeout: 10000ms"), + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsSupported).toBe(false); + expect(result.wsTransport).toBe("unsupported"); + expect(result.wsFallbackReason).toContain("Handshake timeout"); + }); + + // ========================================================================= + // 4. Captures handshake latency, event count, terminal model + // ========================================================================= + + it("captures handshake latency, event count, terminal model", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 87, + events: [ + { type: "response.output_text.delta", data: {} }, + { type: "response.output_text.delta", data: {} }, + { type: "response.output_text.delta", data: {} }, + { type: "response.completed", data: {} }, + ], + model: "gpt-5-codex", + usage: { input_tokens: 200, output_tokens: 100, total_tokens: 300 }, + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsHandshakeMs).toBe(87); + expect(result.wsEventCount).toBe(4); + expect(result.wsTerminalModel).toBe("gpt-5-codex"); + }); + + // ========================================================================= + // 5. Captures usage from terminal event + // ========================================================================= + + it("captures usage from terminal event", async () => { + const usage = { + input_tokens: 500, + output_tokens: 200, + total_tokens: 700, + output_tokens_details: { reasoning_tokens: 50 }, + }; + + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 50, + events: [{ type: "response.completed", data: {} }], + model: "gpt-4o", + usage, + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsTerminalUsage).toEqual(usage); + }); + + // ========================================================================= + // 6. Reports fallback reason when WS fails with recoverable error + // ========================================================================= + + it("reports fallback reason when WS fails with recoverable error", async () => { + // Handshake succeeded (handshakeMs present) but server returned an error frame + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: false, + handshakeMs: 30, + events: [{ type: "error", data: {} }], + error: { + error: { + type: "invalid_request_error", + message: "Model not found", + code: "invalid_model", + }, + }, + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + // Handshake succeeded -> provider supports WS + expect(result.wsSupported).toBe(true); + expect(result.wsTransport).toBe("websocket"); + expect(result.wsFallbackReason).toBeDefined(); + expect(result.wsHandshakeMs).toBe(30); + expect(result.wsEventCount).toBe(1); + }); + + // ========================================================================= + // 7. WsProbeResult type has all required fields + // ========================================================================= + + it("WsProbeResult type has all required fields", () => { + // Compile-time verification: this must compile without errors + const successResult: WsProbeResult = { + wsSupported: true, + wsTransport: "websocket", + wsHandshakeMs: 100, + wsEventCount: 5, + wsFallbackReason: undefined, + wsTerminalModel: "gpt-4o", + wsTerminalUsage: { input_tokens: 10, output_tokens: 5 }, + }; + + const unsupportedResult: WsProbeResult = { + wsSupported: false, + wsTransport: "unsupported", + wsFallbackReason: "Connection refused", + }; + + const fallbackResult: WsProbeResult = { + wsSupported: false, + wsTransport: "http_fallback", + wsFallbackReason: "Provider does not support WS", + }; + + // Runtime check: all required fields exist + expect(successResult).toHaveProperty("wsSupported"); + expect(successResult).toHaveProperty("wsTransport"); + expect(successResult).toHaveProperty("wsHandshakeMs"); + expect(successResult).toHaveProperty("wsEventCount"); + expect(successResult).toHaveProperty("wsTerminalModel"); + expect(successResult).toHaveProperty("wsTerminalUsage"); + + expect(unsupportedResult).toHaveProperty("wsSupported"); + expect(unsupportedResult).toHaveProperty("wsTransport"); + expect(unsupportedResult).toHaveProperty("wsFallbackReason"); + + // Transport enum values + expect(["websocket", "http_fallback", "unsupported"]).toContain(successResult.wsTransport); + expect(["websocket", "http_fallback", "unsupported"]).toContain(unsupportedResult.wsTransport); + expect(["websocket", "http_fallback", "unsupported"]).toContain(fallbackResult.wsTransport); + }); + + // ========================================================================= + // 8. Works with cx_base preset data + // ========================================================================= + + it("works with cx_base preset data (model extraction, input formatting)", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 60, + events: [{ type: "response.completed", data: {} }], + model: "gpt-5-codex", + usage: { input_tokens: 100, output_tokens: 20, total_tokens: 120 }, + }); + + const result = await probeProviderWebSocket(defaultConfig({ preset: "cx_base" })); + + // Verify the adapter was created with correct options + const ctorArgs = getCtorArgs(); + expect(ctorArgs[0]).toEqual( + expect.objectContaining({ + providerBaseUrl: "https://api.openai.com", + apiKey: "sk-test-123", + }) + ); + + // Verify executeTurn was called with preset payload + const adapter = getLastAdapter()!; + const payload = adapter.executeTurn.mock.calls[0][0] as Record; + expect(payload.model).toBe("gpt-5-codex"); // cx_base default model + expect(payload).toHaveProperty("input"); + expect(payload).toHaveProperty("instructions"); + + // Verify result + expect(result.wsSupported).toBe(true); + expect(result.wsTerminalModel).toBe("gpt-5-codex"); + }); + + // ========================================================================= + // Additional edge cases + // ========================================================================= + + it("uses custom model when provided with preset", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 50, + events: [{ type: "response.completed", data: {} }], + model: "o4-mini", + usage: { input_tokens: 50, output_tokens: 10, total_tokens: 60 }, + }); + + await probeProviderWebSocket(defaultConfig({ preset: "cx_base", model: "o4-mini" })); + + const payload = getLastAdapter()!.executeTurn.mock.calls[0][0] as Record; + expect(payload.model).toBe("o4-mini"); + }); + + it("handles connection refused error as unsupported", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: false, + events: [], + error: new Error("connect ECONNREFUSED 127.0.0.1:443"), + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsSupported).toBe(false); + expect(result.wsTransport).toBe("unsupported"); + expect(result.wsFallbackReason).toContain("ECONNREFUSED"); + }); + + it("handles executeTurn rejection gracefully", async () => { + const adapter = getLastAdapter()!; + adapter.executeTurn.mockRejectedValueOnce(new Error("Unexpected internal error")); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsSupported).toBe(false); + expect(result.wsTransport).toBe("unsupported"); + expect(result.wsFallbackReason).toContain("Unexpected internal error"); + // Adapter should be closed on error + expect(adapter.close).toHaveBeenCalled(); + }); + + it("handles completed turn with no usage gracefully", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 100, + events: [{ type: "response.completed", data: {} }], + model: "gpt-4o", + // No usage field + }); + + const result = await probeProviderWebSocket(defaultConfig()); + + expect(result.wsSupported).toBe(true); + expect(result.wsTransport).toBe("websocket"); + expect(result.wsTerminalModel).toBe("gpt-4o"); + expect(result.wsTerminalUsage).toBeUndefined(); + }); + + it("defaults to cx_base preset when none specified", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 50, + events: [{ type: "response.completed", data: {} }], + model: "gpt-5-codex", + }); + + await probeProviderWebSocket(defaultConfig()); + + const payload = getLastAdapter()!.executeTurn.mock.calls[0][0] as Record; + // cx_base default model + expect(payload.model).toBe("gpt-5-codex"); + // cx_base has instructions field + expect(payload).toHaveProperty("instructions"); + }); + + it("passes timeout config to adapter options", async () => { + getLastAdapter()!.executeTurn.mockResolvedValueOnce({ + completed: true, + terminalType: "response.completed", + handshakeMs: 50, + events: [{ type: "response.completed", data: {} }], + model: "gpt-4o", + }); + + await probeProviderWebSocket(defaultConfig({ timeoutMs: 5000 })); + + // Verify adapter was configured with timeout-derived values + const ctorArgs = getCtorArgs(); + const options = ctorArgs[0] as Record; + expect(options).toHaveProperty("handshakeTimeoutMs"); + expect(options).toHaveProperty("idleTimeoutMs"); + expect(options.handshakeTimeoutMs).toBeLessThanOrEqual(5000); + expect(options.idleTimeoutMs).toBe(5000); + }); +}); diff --git a/tests/unit/provider-testing/ws-test-status.test.tsx b/tests/unit/provider-testing/ws-test-status.test.tsx new file mode 100644 index 000000000..b58f8692b --- /dev/null +++ b/tests/unit/provider-testing/ws-test-status.test.tsx @@ -0,0 +1,209 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test } from "vitest"; +import { WsTestStatus } from "@/app/[locale]/settings/providers/_components/forms/ws-test-status"; +import type { WsTestResultFields } from "@/lib/provider-testing/ws-types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ru"] as const; +const WS_KEYS = [ + "status", + "supported", + "unsupported", + "fallback", + "handshakeMs", + "eventCount", + "fallbackReason", +] as const; + +function loadApiTestMessages(locale: string): Record { + const filePath = path.join( + process.cwd(), + "messages", + locale, + "settings/providers/form/apiTest.json" + ); + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function renderWithIntl(node: ReactNode, messages?: Record) { + const msgs = messages ?? loadApiTestMessages("en"); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Component rendering tests +// --------------------------------------------------------------------------- + +describe("WsTestStatus", () => { + test('renders "Supported" badge when wsSupported=true, wsTransport="websocket"', () => { + const result: WsTestResultFields = { + wsSupported: true, + wsTransport: "websocket", + wsHandshakeMs: 120, + wsEventCount: 8, + }; + + const { container, unmount } = renderWithIntl(); + + const root = container.querySelector('[data-testid="ws-test-status"]'); + expect(root).not.toBeNull(); + + const badge = container.querySelector('[data-testid="ws-badge"]'); + expect(badge).not.toBeNull(); + expect(badge!.textContent).toBe("Supported"); + + unmount(); + }); + + test('renders "Unsupported" badge when wsTransport="unsupported"', () => { + const result: WsTestResultFields = { + wsSupported: false, + wsTransport: "unsupported", + wsFallbackReason: "Connection refused", + }; + + const { container, unmount } = renderWithIntl(); + + const badge = container.querySelector('[data-testid="ws-badge"]'); + expect(badge).not.toBeNull(); + expect(badge!.textContent).toBe("Unsupported"); + + unmount(); + }); + + test('renders "HTTP Fallback" badge when wsTransport="http_fallback"', () => { + const result: WsTestResultFields = { + wsSupported: false, + wsTransport: "http_fallback", + wsFallbackReason: "Provider does not support WS", + }; + + const { container, unmount } = renderWithIntl(); + + const badge = container.querySelector('[data-testid="ws-badge"]'); + expect(badge).not.toBeNull(); + expect(badge!.textContent).toBe("HTTP Fallback"); + + unmount(); + }); + + test("shows handshake latency when wsHandshakeMs is provided", () => { + const result: WsTestResultFields = { + wsSupported: true, + wsTransport: "websocket", + wsHandshakeMs: 250, + }; + + const { container, unmount } = renderWithIntl(); + + const handshake = container.querySelector('[data-testid="ws-handshake"]'); + expect(handshake).not.toBeNull(); + expect(handshake!.textContent).toContain("250ms"); + + unmount(); + }); + + test("shows event count when wsEventCount is provided", () => { + const result: WsTestResultFields = { + wsSupported: true, + wsTransport: "websocket", + wsEventCount: 12, + }; + + const { container, unmount } = renderWithIntl(); + + const eventCount = container.querySelector('[data-testid="ws-event-count"]'); + expect(eventCount).not.toBeNull(); + expect(eventCount!.textContent).toContain("12"); + + unmount(); + }); + + test("shows fallback reason when wsFallbackReason is provided", () => { + const result: WsTestResultFields = { + wsSupported: false, + wsTransport: "unsupported", + wsFallbackReason: "Connection refused", + }; + + const { container, unmount } = renderWithIntl(); + + const reason = container.querySelector('[data-testid="ws-fallback-reason"]'); + expect(reason).not.toBeNull(); + expect(reason!.textContent).toContain("Connection refused"); + + unmount(); + }); + + test("renders nothing when no WS fields are provided", () => { + const result: WsTestResultFields = {}; + + const { container, unmount } = renderWithIntl(); + + const root = container.querySelector('[data-testid="ws-test-status"]'); + expect(root).toBeNull(); + + unmount(); + }); +}); + +// --------------------------------------------------------------------------- +// i18n key presence test +// --------------------------------------------------------------------------- + +describe("WsTestStatus i18n keys", () => { + test("all required ws.* keys exist in all 5 locale files", () => { + for (const locale of LOCALES) { + const messages = loadApiTestMessages(locale); + const ws = messages.ws as Record | undefined; + + expect(ws, `messages/${locale} is missing the "ws" section`).toBeDefined(); + + for (const key of WS_KEYS) { + expect(ws![key], `messages/${locale}/apiTest.json is missing ws.${key}`).toBeDefined(); + expect( + typeof ws![key], + `messages/${locale}/apiTest.json ws.${key} should be a string` + ).toBe("string"); + expect( + (ws![key] as string).length, + `messages/${locale}/apiTest.json ws.${key} should not be empty` + ).toBeGreaterThan(0); + } + } + }); +}); diff --git a/tests/unit/ws/billing-parity.test.ts b/tests/unit/ws/billing-parity.test.ts new file mode 100644 index 000000000..3dda25b15 --- /dev/null +++ b/tests/unit/ws/billing-parity.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it } from "vitest"; +import type { ResponseUsage } from "@/lib/ws/frames"; +import type { ModelPriceData } from "@/types/model-price"; +import { REDACTED_MARKER } from "@/lib/utils/message-redaction"; +import { + buildWsTraceMetadata, + redactWsEventPayload, + settleWsTurnBilling, + wsUsageToMetrics, +} from "@/app/v1/_lib/ws/billing-parity"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePriceData(overrides: Partial = {}): ModelPriceData { + return { + input_cost_per_token: 0.000003, // $3/MTok + output_cost_per_token: 0.000015, // $15/MTok + cache_creation_input_token_cost: 0.00000375, // 1.25x input + cache_read_input_token_cost: 0.0000003, // 0.1x input + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// settleWsTurnBilling +// --------------------------------------------------------------------------- + +describe("settleWsTurnBilling", () => { + it("extracts correct token counts from usage", () => { + const result = settleWsTurnBilling({ + usage: { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }, + }); + + expect(result.inputTokens).toBe(100); + expect(result.outputTokens).toBe(50); + expect(result.usageMetrics).not.toBeNull(); + expect(result.usageMetrics!.input_tokens).toBe(100); + expect(result.usageMetrics!.output_tokens).toBe(50); + }); + + it("uses actual service_tier from terminal for pricing (not requested)", () => { + // Scenario: client requested "priority" but terminal says "default" + const resultDefaultActual = settleWsTurnBilling({ + usage: { input_tokens: 1000, output_tokens: 500 }, + serviceTier: "default", + requestedServiceTier: "priority", + }); + expect(resultDefaultActual.priorityServiceTierApplied).toBe(false); + + // Reverse: actual is priority, requested is default + const resultPriorityActual = settleWsTurnBilling({ + usage: { input_tokens: 1000, output_tokens: 500 }, + serviceTier: "priority", + requestedServiceTier: "default", + }); + expect(resultPriorityActual.priorityServiceTierApplied).toBe(true); + + // With priority pricing, cost should differ when price data has priority fields + const priceData = makePriceData({ + input_cost_per_token_priority: 0.000006, // 2x base + output_cost_per_token_priority: 0.00006, // 4x base + }); + const costDefault = settleWsTurnBilling({ + usage: { input_tokens: 1000, output_tokens: 500 }, + serviceTier: "default", + priceData, + }); + const costPriority = settleWsTurnBilling({ + usage: { input_tokens: 1000, output_tokens: 500 }, + serviceTier: "priority", + priceData, + }); + // Priority pricing should produce a higher cost + expect(Number(costPriority.costUsd)).toBeGreaterThan(Number(costDefault.costUsd)); + }); + + it("handles missing/null usage gracefully", () => { + const result = settleWsTurnBilling({ + usage: undefined, + model: "gpt-4o", + priceData: makePriceData(), + }); + + expect(result.usageMetrics).toBeNull(); + expect(result.inputTokens).toBeUndefined(); + expect(result.outputTokens).toBeUndefined(); + expect(result.costUsd).toBeUndefined(); + expect(result.costBreakdown).toBeUndefined(); + expect(result.priorityServiceTierApplied).toBe(false); + }); + + it("handles response.failed with partial usage", () => { + const result = settleWsTurnBilling({ + usage: { + input_tokens: 500, + output_tokens: 0, + }, + priceData: makePriceData(), + }); + + expect(result.inputTokens).toBe(500); + expect(result.outputTokens).toBe(0); + expect(result.usageMetrics).not.toBeNull(); + // Cost is still computed from partial usage + expect(result.costBreakdown).toBeDefined(); + expect(result.costBreakdown!.input).toBeCloseTo(0.0015, 6); // 500 * 0.000003 + expect(result.costBreakdown!.output).toBe(0); + expect(result.costBreakdown!.total).toBeCloseTo(0.0015, 6); + }); + + it("extracts cache tokens from passthrough usage fields", () => { + // WS usage schema uses .passthrough() so cache fields may be present + const usage = { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + } as ResponseUsage; + + const result = settleWsTurnBilling({ usage }); + expect(result.cacheCreationInputTokens).toBe(200); + expect(result.cacheReadInputTokens).toBe(300); + }); + + it("falls back to requested tier when actual tier is absent", () => { + const result = settleWsTurnBilling({ + usage: { input_tokens: 100, output_tokens: 50 }, + serviceTier: undefined, + requestedServiceTier: "priority", + }); + expect(result.priorityServiceTierApplied).toBe(true); + }); + + it("skips cost calculation when priceData is absent", () => { + const result = settleWsTurnBilling({ + usage: { input_tokens: 1000, output_tokens: 500 }, + }); + expect(result.costUsd).toBeUndefined(); + expect(result.costBreakdown).toBeUndefined(); + // Token counts should still be populated + expect(result.inputTokens).toBe(1000); + expect(result.outputTokens).toBe(500); + }); +}); + +// --------------------------------------------------------------------------- +// buildWsTraceMetadata +// --------------------------------------------------------------------------- + +describe("buildWsTraceMetadata", () => { + it("includes transport metadata", () => { + const metadata = buildWsTraceMetadata({ + handshakeMs: 45, + eventCount: 12, + terminalType: "response.completed", + model: "gpt-4o", + serviceTier: "default", + durationMs: 3500, + statusCode: 200, + }); + + expect(metadata.transport).toBe("websocket"); + expect(metadata.handshakeMs).toBe(45); + expect(metadata.eventCount).toBe(12); + expect(metadata.durationMs).toBe(3500); + expect(metadata.statusCode).toBe(200); + }); + + it("includes terminal event type and model", () => { + const metadata = buildWsTraceMetadata({ + eventCount: 5, + terminalType: "response.failed", + model: "gpt-4o-mini", + serviceTier: "priority", + durationMs: 1200, + errorMessage: "Rate limit exceeded", + }); + + expect(metadata.terminalType).toBe("response.failed"); + expect(metadata.model).toBe("gpt-4o-mini"); + expect(metadata.serviceTier).toBe("priority"); + expect(metadata.errorMessage).toBe("Rate limit exceeded"); + }); +}); + +// --------------------------------------------------------------------------- +// redactWsEventPayload +// --------------------------------------------------------------------------- + +describe("redactWsEventPayload", () => { + it("redacts reasoning.summary content", () => { + const event = { + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_001", + summary: [{ type: "summary_text", text: "The user is asking about sensitive data..." }], + }, + }; + + const redacted = redactWsEventPayload(event); + const item = redacted.item as Record; + const summary = item.summary as Array>; + + expect(summary[0].text).toBe(REDACTED_MARKER); + expect(summary[0].type).toBe("summary_text"); // type preserved + }); + + it("redacts reasoning.encrypted_content", () => { + const event = { + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_002", + encrypted_content: "base64-encoded-encrypted-reasoning-data", + summary: [], + }, + }; + + const redacted = redactWsEventPayload(event); + const item = redacted.item as Record; + expect(item.encrypted_content).toBe(REDACTED_MARKER); + }); + + it("redacts tool call arguments", () => { + const event = { + type: "response.output_item.done", + item: { + type: "function_call", + id: "fc_001", + name: "get_weather", + call_id: "call_abc", + arguments: '{"location": "San Francisco", "api_key": "secret123"}', + }, + }; + + const redacted = redactWsEventPayload(event); + const item = redacted.item as Record; + + expect(item.arguments).toBe(REDACTED_MARKER); + expect(item.name).toBe("get_weather"); // metadata preserved + expect(item.call_id).toBe("call_abc"); // metadata preserved + expect(item.id).toBe("fc_001"); // id preserved + }); + + it("preserves non-sensitive event data", () => { + const event = { + type: "response.created", + response: { + id: "resp_001", + object: "response", + status: "in_progress", + model: "gpt-4o", + service_tier: "default", + }, + }; + + const redacted = redactWsEventPayload(event); + expect(redacted.type).toBe("response.created"); + const response = redacted.response as Record; + expect(response.id).toBe("resp_001"); + expect(response.model).toBe("gpt-4o"); + expect(response.status).toBe("in_progress"); + expect(response.service_tier).toBe("default"); + }); + + it("redacts terminal event response.output[] items", () => { + const event = { + type: "response.completed", + response: { + id: "resp_001", + status: "completed", + model: "gpt-4o", + output: [ + { + type: "message", + content: [{ type: "output_text", text: "Secret answer here" }], + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Internal reasoning" }], + encrypted_content: "enc-data", + }, + { + type: "function_call", + name: "search", + arguments: '{"query": "sensitive"}', + }, + ], + }, + }; + + const redacted = redactWsEventPayload(event); + const response = redacted.response as Record; + const output = response.output as Array>; + + // Message content redacted + const msg = output[0]; + const content = msg.content as Array>; + expect(content[0].text).toBe(REDACTED_MARKER); + + // Reasoning summary + encrypted_content redacted + const reasoning = output[1]; + const summary = reasoning.summary as Array>; + expect(summary[0].text).toBe(REDACTED_MARKER); + expect(reasoning.encrypted_content).toBe(REDACTED_MARKER); + + // Function call arguments redacted + const funcCall = output[2]; + expect(funcCall.arguments).toBe(REDACTED_MARKER); + expect(funcCall.name).toBe("search"); // metadata preserved + }); + + it("redacts delta events for sensitive content types", () => { + const textDelta = { + type: "response.output_text.delta", + delta: "Hello, world!", + output_index: 0, + }; + const redactedText = redactWsEventPayload(textDelta); + expect(redactedText.delta).toBe(REDACTED_MARKER); + expect(redactedText.output_index).toBe(0); // preserved + + const reasoningDelta = { + type: "response.reasoning_summary_text.delta", + delta: "thinking about...", + }; + expect(redactWsEventPayload(reasoningDelta).delta).toBe(REDACTED_MARKER); + + const funcArgsDelta = { + type: "response.function_call_arguments.delta", + delta: '{"arg":', + }; + expect(redactWsEventPayload(funcArgsDelta).delta).toBe(REDACTED_MARKER); + }); + + it("preserves non-sensitive delta events", () => { + const audioDelta = { + type: "response.audio.delta", + delta: "base64audiodata", + }; + expect(redactWsEventPayload(audioDelta).delta).toBe("base64audiodata"); + }); + + it("does not mutate the original event object", () => { + const original = { + type: "response.output_item.done", + item: { + type: "function_call", + name: "test", + arguments: '{"secret": true}', + }, + }; + + const originalArgs = (original.item as Record).arguments; + redactWsEventPayload(original); + expect((original.item as Record).arguments).toBe(originalArgs); + }); +}); + +// --------------------------------------------------------------------------- +// wsUsageToMetrics +// --------------------------------------------------------------------------- + +describe("wsUsageToMetrics", () => { + it("returns null for undefined usage", () => { + expect(wsUsageToMetrics(undefined)).toBeNull(); + }); + + it("maps basic token counts", () => { + const metrics = wsUsageToMetrics({ + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }); + + expect(metrics).not.toBeNull(); + expect(metrics!.input_tokens).toBe(100); + expect(metrics!.output_tokens).toBe(50); + }); + + it("extracts cache fields from passthrough", () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + } as ResponseUsage; + + const metrics = wsUsageToMetrics(usage); + expect(metrics!.cache_creation_input_tokens).toBe(200); + expect(metrics!.cache_read_input_tokens).toBe(300); + }); + + it("leaves cache fields undefined when not present", () => { + const metrics = wsUsageToMetrics({ + input_tokens: 100, + output_tokens: 50, + }); + + expect(metrics!.cache_creation_input_tokens).toBeUndefined(); + expect(metrics!.cache_read_input_tokens).toBeUndefined(); + }); +}); From 30fb85c2d140075dd32648e6c484fbf5a5397658 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 10:02:00 +0800 Subject: [PATCH 4/4] feat(ws): implement handleTurn orchestration and complete WS pipeline Connect all WebSocket components into a working end-to-end pipeline: - Add ProxySession.fromWebSocket() factory for synthetic sessions - Implement handleTurn() with guard pipeline, outbound adapter, event relay, billing settlement, and session continuity - Wire response.cancel to terminate upstream connections - Add integration tests for full orchestration flow --- src/app/v1/_lib/proxy/session.ts | 63 ++ src/app/v1/_lib/ws/ingress-handler.ts | 197 +++- .../ws/ingress-handler-integration.test.ts | 840 ++++++++++++++++++ tests/unit/ws/ingress-handler.test.ts | 30 + 4 files changed, 1116 insertions(+), 14 deletions(-) create mode 100644 tests/unit/ws/ingress-handler-integration.test.ts diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 3c4030ca5..c417ae6b2 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from "node:http"; import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; @@ -235,6 +236,68 @@ export class ProxySession { }); } + /** + * Create a ProxySession from a WebSocket upgrade request. + * + * Used by the WS ingress handler (delayed bridging) to run + * deferred guard steps (model, provider, messageContext) without + * a Hono Context. Auth is pre-populated from upgrade-time validation. + * + * The synthetic URL is http://localhost/v1/responses so that: + * - classifyTransport() recognises the /responses endpoint + * - resolveEndpointPolicy() returns the default chat policy + */ + static fromWebSocket(params: { + req: IncomingMessage; + auth: { user: User; key: Key; apiKey: string }; + model: string; + requestBody: Record; + }): ProxySession { + const startTime = Date.now(); + const requestUrl = new URL("http://localhost/v1/responses"); + + const headers = new Headers(); + for (const [key, value] of Object.entries(params.req.headers)) { + if (typeof value === "string") { + headers.set(key, value); + } else if (Array.isArray(value)) { + headers.set(key, value.join(", ")); + } + } + + const headerLog = formatHeadersForLog(headers); + const userAgent = headers.get("user-agent") || null; + + const request: ProxyRequestPayload = { + message: params.requestBody, + log: JSON.stringify(params.requestBody).slice(0, 2000), + model: params.model, + }; + + const session = new ProxySession({ + startTime, + method: "POST", + requestUrl, + headers, + headerLog, + request, + userAgent, + // WS path never touches Hono context; guards that need it are skipped + context: null as unknown as Context, + // WS lifecycle managed separately via activeAdapter + clientAbortSignal: null, + }); + + session.setAuthState({ + user: params.auth.user, + key: params.auth.key, + apiKey: params.auth.apiKey, + success: true, + }); + + return session; + } + /** * 检查 header 是否被过滤器修改过。 * diff --git a/src/app/v1/_lib/ws/ingress-handler.ts b/src/app/v1/_lib/ws/ingress-handler.ts index a7aafe806..ac9898d1d 100644 --- a/src/app/v1/_lib/ws/ingress-handler.ts +++ b/src/app/v1/_lib/ws/ingress-handler.ts @@ -6,10 +6,19 @@ import { logger } from "@/lib/logger"; import { parseClientFrame } from "@/lib/ws/frame-parser"; import type { ResponseCreateFrame } from "@/lib/ws/frames"; import { validateApiKeyAndGetUser } from "@/repository/key"; +import { updateMessageRequestCost, updateMessageRequestDetails } from "@/repository/message"; import type { Key } from "@/types/key"; +import type { Provider } from "@/types/provider"; import type { User } from "@/types/user"; import { extractApiKeyFromHeaders } from "../proxy/auth-guard"; +import { GuardPipelineBuilder } from "../proxy/guard-pipeline"; +import { ProxySession } from "../proxy/session"; +import { classifyTransport } from "../proxy/transport-classifier"; +import { buildWsTraceMetadata, settleWsTurnBilling } from "./billing-parity"; +import { type SettlementResult, WsEventBridge } from "./event-bridge"; +import { OutboundWsAdapter } from "./outbound-adapter"; +import { createWsTurnContext, updateSessionFromTerminal } from "./session-continuity"; // --------------------------------------------------------------------------- // Types @@ -67,6 +76,7 @@ export class WsIngressHandler { private currentMeta: TurnMeta | null = null; private auth: WsAuthContext | null = null; private ip: string; + private activeAdapter: OutboundWsAdapter | null = null; constructor( private ws: WebSocket, @@ -224,7 +234,10 @@ export class WsIngressHandler { if (frame.type === "response.cancel") { if (this.state === "processing") { logger.debug("[WsIngress] Cancel received for active turn", { turn: this.turnCount }); - // TODO (T7): Signal cancellation to outbound adapter + if (this.activeAdapter) { + this.activeAdapter.close(); + this.activeAdapter = null; + } this.state = "waiting"; this.currentMeta = null; } else { @@ -263,20 +276,176 @@ export class WsIngressHandler { * State management: the caller (.finally()) sets state back to "waiting". * handleTurn does NOT manage connection state. * - * For T6, this implements the delayed bridging skeleton. - * The actual outbound adapter integration happens in T7 (event bridge). + * Pipeline: + * 1. Create synthetic ProxySession from WS upgrade request + auth + * 2. Run deferred guard pipeline (model, provider, messageContext) + * 3. Classify transport (must be WS-eligible) + * 4. Execute turn via OutboundWsAdapter + * 5. Relay events to client via WsEventBridge + * 6. Settle billing + session continuity */ - async handleTurn(_frame: ResponseCreateFrame): Promise { - // TODO (T7/T8): Create synthetic ProxySession from this.req + this.auth - // TODO (T7/T8): Run deferred guard pipeline (model, rateLimit, provider) - // TODO (T7/T8): Use outbound adapter or HTTP fallback - // TODO (T7/T8): Relay events back to client - - // Placeholder: WS ingress operational but bridging not connected - this.sendError( - "server_error", - "WebSocket ingress operational but upstream bridging not yet implemented (pending T7/T8)" - ); + async handleTurn(frame: ResponseCreateFrame): Promise { + if (!this.auth) { + throw new Error("Not authenticated"); + } + + // Capture meta early -- cancel can clear this.currentMeta mid-turn + const turnMeta = this.currentMeta!; + + // 1. Create synthetic ProxySession + const session = ProxySession.fromWebSocket({ + req: this.req, + auth: this.auth, + model: frame.response.model, + requestBody: frame.response as Record, + }); + + // 2. Run deferred guard pipeline (model validation, provider selection, billing record) + const pipeline = GuardPipelineBuilder.build({ + steps: ["model", "provider", "messageContext"], + }); + const guardResponse = await pipeline.run(session); + if (guardResponse) { + let errorType = "guard_error"; + let errorMessage = `Request rejected (${guardResponse.status})`; + try { + const body = await guardResponse.text(); + const parsed = JSON.parse(body) as { + error?: { type?: string; message?: string }; + }; + if (parsed.error?.type) errorType = parsed.error.type; + if (parsed.error?.message) errorMessage = parsed.error.message; + } catch { + // Use defaults + } + this.sendError(errorType, errorMessage); + return; + } + + // 3. Verify provider selected + const provider = session.provider; + if (!provider) { + this.sendError("server_error", "No provider available for the requested model"); + return; + } + + // 4. Classify transport + const decision = await classifyTransport(session, provider); + if (decision.transport !== "websocket") { + this.sendError( + "invalid_request_error", + `WebSocket transport not available for this provider (${decision.reason}); use the HTTP endpoint instead` + ); + return; + } + + // 5. Execute turn via outbound adapter + const adapter = new OutboundWsAdapter({ + providerBaseUrl: provider.url, + apiKey: provider.key, + }); + this.activeAdapter = adapter; + + try { + const turnResult = await adapter.executeTurn(frame.response as Record); + + // 6. Relay all events to client via event bridge + const bridge = new WsEventBridge(); + for (const event of turnResult.events) { + bridge.relayEvent( + this.ws, + event as { type: string; data: unknown }, + JSON.stringify(event.data) + ); + } + + // Settle error if bridge didn't receive a terminal event + if (!bridge.isSettled) { + if (turnResult.error) { + const msg = + turnResult.error instanceof Error ? turnResult.error.message : "Upstream error"; + bridge.settleError(msg); + // Network errors weren't in the event stream; notify client + if (turnResult.error instanceof Error) { + this.sendError("server_error", msg); + } + } else { + bridge.settleError("Turn ended without terminal event"); + } + } + + // 7. Billing settlement + const settlement = bridge.getSettlement(); + if (settlement && session.messageContext) { + await this.settleBilling(session, settlement, provider, turnMeta, turnResult.handshakeMs); + } + } finally { + this.activeAdapter = null; + } + } + + /** + * Settle billing, persist cost/details, update session binding. + * Best-effort: errors are logged but do not fail the turn. + */ + private async settleBilling( + session: ProxySession, + settlement: SettlementResult, + provider: Provider, + turnMeta: TurnMeta, + handshakeMs?: number + ): Promise { + try { + const turnContext = createWsTurnContext(this.auth!, turnMeta); + + const priceData = await session.getCachedPriceDataByBillingSource(provider); + const billingResult = settleWsTurnBilling({ + usage: settlement.usage, + serviceTier: settlement.serviceTier, + requestedServiceTier: turnMeta.serviceTier, + priceData: priceData ?? undefined, + costMultiplier: provider.costMultiplier ?? 1.0, + }); + + await updateMessageRequestCost(session.messageContext!.id, billingResult.costUsd); + + const statusCode = + settlement.status === "completed" || settlement.status === "incomplete" ? 200 : 500; + + await updateMessageRequestDetails(session.messageContext!.id, { + statusCode, + inputTokens: billingResult.inputTokens, + outputTokens: billingResult.outputTokens, + cacheCreationInputTokens: billingResult.cacheCreationInputTokens, + cacheReadInputTokens: billingResult.cacheReadInputTokens, + model: settlement.model ?? turnMeta.model, + providerId: provider.id, + providerChain: session.getProviderChain(), + }); + + await updateSessionFromTerminal(turnContext, settlement, session.sessionId, provider.id); + + // Best-effort trace metadata (non-blocking) + try { + buildWsTraceMetadata({ + handshakeMs, + eventCount: settlement.eventCount, + terminalType: settlement.terminalType, + model: settlement.model, + serviceTier: settlement.serviceTier, + durationMs: settlement.durationMs, + statusCode, + errorMessage: settlement.errorMessage, + }); + } catch { + // Best-effort, swallow errors + } + } catch (error) { + logger.error("[WsIngress] Billing settlement failed", { + error, + turn: this.turnCount, + }); + } } /** Send an error frame to the client */ diff --git a/tests/unit/ws/ingress-handler-integration.test.ts b/tests/unit/ws/ingress-handler-integration.test.ts new file mode 100644 index 000000000..f649559d0 --- /dev/null +++ b/tests/unit/ws/ingress-handler-integration.test.ts @@ -0,0 +1,840 @@ +/** + * Integration tests for WsIngressHandler.handleTurn orchestration. + * + * Tests the full pipeline: ProxySession creation -> guard pipeline -> + * transport classification -> outbound adapter -> event bridge relay -> + * billing settlement -> session continuity. + * + * All external dependencies are mocked; these tests verify orchestration + * logic rather than individual component behavior. + */ + +import { EventEmitter } from "node:events"; +import type { IncomingMessage } from "node:http"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mock instances (vi.hoisted ensures these exist before vi.mock factories run) +// --------------------------------------------------------------------------- + +const { + mockPipelineRun, + mockExecuteTurn, + mockAdapterClose, + mockRelayEvent, + mockSettleError, + mockGetSettlement, + mockBridgeReset, + mockBridgeIsSettledRef, +} = vi.hoisted(() => ({ + mockPipelineRun: vi.fn(), + mockExecuteTurn: vi.fn(), + mockAdapterClose: vi.fn(), + mockRelayEvent: vi.fn().mockReturnValue(false), + mockSettleError: vi.fn(), + mockGetSettlement: vi.fn().mockReturnValue(null), + mockBridgeReset: vi.fn(), + mockBridgeIsSettledRef: { value: false }, +})); + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: vi.fn(), +})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + isResponsesWebSocketEnabled: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/proxy/auth-guard", () => ({ + extractApiKeyFromHeaders: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/session", () => ({ + ProxySession: { + fromWebSocket: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/guard-pipeline", () => ({ + GuardPipelineBuilder: { + build: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/transport-classifier", () => ({ + classifyTransport: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/ws/outbound-adapter", () => ({ + OutboundWsAdapter: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/ws/event-bridge", () => ({ + WsEventBridge: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/ws/billing-parity", () => ({ + settleWsTurnBilling: vi.fn(), + buildWsTraceMetadata: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/ws/session-continuity", () => ({ + createWsTurnContext: vi.fn(), + updateSessionFromTerminal: vi.fn(), +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestCost: vi.fn(), + updateMessageRequestDetails: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { GuardPipelineBuilder } from "@/app/v1/_lib/proxy/guard-pipeline"; +import { classifyTransport } from "@/app/v1/_lib/proxy/transport-classifier"; +import { OutboundWsAdapter } from "@/app/v1/_lib/ws/outbound-adapter"; +import { WsEventBridge } from "@/app/v1/_lib/ws/event-bridge"; +import { settleWsTurnBilling, buildWsTraceMetadata } from "@/app/v1/_lib/ws/billing-parity"; +import { + createWsTurnContext, + updateSessionFromTerminal, +} from "@/app/v1/_lib/ws/session-continuity"; +import { updateMessageRequestCost, updateMessageRequestDetails } from "@/repository/message"; +import { WsIngressHandler } from "@/app/v1/_lib/ws/ingress-handler"; +import { extractApiKeyFromHeaders } from "@/app/v1/_lib/proxy/auth-guard"; +import { isResponsesWebSocketEnabled } from "@/lib/config/system-settings-cache"; +import { validateApiKeyAndGetUser } from "@/repository/key"; + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const validUser = { id: 1, name: "test-user", isEnabled: true, role: "user" }; +const validKey = { id: 10, name: "test-key", userId: 1, isEnabled: true }; +const validProvider = { + id: 5, + name: "test-provider", + url: "https://api.openai.com", + key: "sk-provider-key", + providerType: "codex", + costMultiplier: 1.0, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WS_OPEN = 1; + +function createMockWs() { + const ws = new EventEmitter() as EventEmitter & { + readyState: number; + OPEN: number; + send: ReturnType; + close: ReturnType; + }; + ws.readyState = WS_OPEN; + ws.OPEN = WS_OPEN; + ws.send = vi.fn(); + ws.close = vi.fn(); + return ws; +} + +function createMockReq(): IncomingMessage { + return { + url: "/v1/responses", + headers: { + host: "localhost:13500", + authorization: "Bearer test-key", + }, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as IncomingMessage; +} + +function makeCreateFrame(model = "o3-pro", overrides: Record = {}): string { + return JSON.stringify({ + type: "response.create", + response: { model, ...overrides }, + }); +} + +function makeCancelFrame(): string { + return JSON.stringify({ type: "response.cancel" }); +} + +async function flush(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} + +function lastSentJson(ws: ReturnType): Record | null { + const calls = ws.send.mock.calls; + if (calls.length === 0) return null; + return JSON.parse(calls[calls.length - 1][0] as string) as Record; +} + +function createMockSession(provider: unknown = null) { + return { + provider, + messageContext: provider + ? { id: 42, createdAt: new Date(), user: validUser, key: validKey, apiKey: "test-key" } + : null, + sessionId: "sess-123", + getProviderChain: vi.fn().mockReturnValue([]), + getCachedPriceDataByBillingSource: vi.fn().mockResolvedValue(null), + setAuthState: vi.fn(), + } as unknown; +} + +function makeCompletedSettlement(overrides: Record = {}) { + return { + status: "completed", + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + model: "gpt-4o", + serviceTier: "default", + promptCacheKey: "cache-key-001", + eventCount: 5, + durationMs: 1200, + terminalType: "response.completed", + ...overrides, + }; +} + +function makeCompletedTurnResult(overrides: Record = {}) { + return { + completed: true, + terminalType: "response.completed", + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + model: "gpt-4o", + serviceTier: "default", + events: [ + { type: "response.created", data: { type: "response.created" } }, + { type: "response.output_item.added", data: { type: "response.output_item.added" } }, + { + type: "response.completed", + data: { + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + model: "gpt-4o", + service_tier: "default", + prompt_cache_key: "cache-key-001", + }, + }, + }, + ], + handshakeMs: 45, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + // Auth mocks + vi.mocked(isResponsesWebSocketEnabled).mockResolvedValue(true); + vi.mocked(extractApiKeyFromHeaders).mockReturnValue("test-api-key"); + vi.mocked(validateApiKeyAndGetUser).mockResolvedValue({ + user: validUser as any, + key: validKey as any, + }); + + // Pipeline mocks + vi.mocked(ProxySession.fromWebSocket).mockReturnValue(createMockSession(validProvider) as any); + vi.mocked(GuardPipelineBuilder.build).mockReturnValue({ run: mockPipelineRun }); + mockPipelineRun.mockResolvedValue(null); // No guard rejection + + // Transport + vi.mocked(classifyTransport).mockResolvedValue({ + transport: "websocket", + reason: "all_conditions_met", + }); + + // Adapter (configured here, not in vi.mock factory, to avoid hoisting issues) + // biome-ignore lint/complexity/useArrowFunction: constructor mocks require function keyword + vi.mocked(OutboundWsAdapter).mockImplementation(function () { + return { + executeTurn: mockExecuteTurn, + close: mockAdapterClose, + } as any; + }); + mockExecuteTurn.mockResolvedValue(makeCompletedTurnResult()); + mockAdapterClose.mockReset(); + + // Bridge (configured here, not in vi.mock factory, to avoid hoisting issues) + // biome-ignore lint/complexity/useArrowFunction: constructor mocks require function keyword + vi.mocked(WsEventBridge).mockImplementation(function () { + const bridge = { + relayEvent: mockRelayEvent, + settleError: mockSettleError, + getSettlement: mockGetSettlement, + reset: mockBridgeReset, + }; + Object.defineProperty(bridge, "isSettled", { + get: () => mockBridgeIsSettledRef.value, + }); + return bridge as any; + }); + mockRelayEvent.mockReturnValue(false); + mockBridgeIsSettledRef.value = true; + mockGetSettlement.mockReturnValue(makeCompletedSettlement()); + mockSettleError.mockReset(); + mockBridgeReset.mockReset(); + + // Billing + vi.mocked(settleWsTurnBilling).mockReturnValue({ + usageMetrics: { input_tokens: 100, output_tokens: 50 }, + inputTokens: 100, + outputTokens: 50, + priorityServiceTierApplied: false, + costUsd: "0.001500", + } as any); + vi.mocked(buildWsTraceMetadata).mockReturnValue({}); + + // Session continuity + vi.mocked(createWsTurnContext).mockReturnValue({ + model: "o3-pro", + previousResponseId: undefined, + promptCacheKey: undefined, + transport: "websocket", + startedAt: Date.now(), + keyId: 10, + userId: 1, + }); + vi.mocked(updateSessionFromTerminal).mockResolvedValue({ + turnContext: {} as any, + sessionUpdated: true, + }); + + // Message repo + vi.mocked(updateMessageRequestCost).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); +}); + +afterEach(() => { + mockBridgeIsSettledRef.value = false; +}); + +// =========================================================================== +// handleTurn integration tests +// =========================================================================== + +describe("WsIngressHandler handleTurn orchestration", () => { + // ------------------------------------------------------------------------- + // Full successful turn + // ------------------------------------------------------------------------- + + describe("successful turn", () => { + test("runs full pipeline: session -> guards -> adapter -> relay -> billing", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("o3-pro")); + await flush(); + + // ProxySession created from WS context + expect(ProxySession.fromWebSocket).toHaveBeenCalledWith( + expect.objectContaining({ + model: "o3-pro", + }) + ); + + // Guard pipeline built with correct steps + expect(GuardPipelineBuilder.build).toHaveBeenCalledWith({ + steps: ["model", "provider", "messageContext"], + }); + + // Pipeline ran + expect(mockPipelineRun).toHaveBeenCalled(); + + // Transport classified + expect(classifyTransport).toHaveBeenCalled(); + + // Adapter created and turn executed + expect(OutboundWsAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + providerBaseUrl: "https://api.openai.com", + apiKey: "sk-provider-key", + }) + ); + expect(mockExecuteTurn).toHaveBeenCalled(); + + // Billing settled + expect(settleWsTurnBilling).toHaveBeenCalled(); + expect(updateMessageRequestCost).toHaveBeenCalledWith(42, "0.001500"); + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 42, + expect.objectContaining({ + statusCode: 200, + inputTokens: 100, + outputTokens: 50, + providerId: 5, + }) + ); + + // Session continuity + expect(updateSessionFromTerminal).toHaveBeenCalled(); + + // Trace metadata + expect(buildWsTraceMetadata).toHaveBeenCalled(); + + // State returned to waiting + expect(handler.connectionState).toBe("waiting"); + }); + + test("relays all events from adapter to client via bridge", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + // 3 events in makeCompletedTurnResult + expect(mockRelayEvent).toHaveBeenCalledTimes(3); + }); + + test("passes handshakeMs to trace metadata", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(buildWsTraceMetadata).toHaveBeenCalledWith( + expect.objectContaining({ handshakeMs: 45 }) + ); + }); + }); + + // ------------------------------------------------------------------------- + // Guard rejection + // ------------------------------------------------------------------------- + + describe("guard rejection", () => { + test("sends error when model guard rejects", async () => { + mockPipelineRun.mockResolvedValue( + new Response( + JSON.stringify({ error: { type: "forbidden", message: "Model o3-pro not allowed" } }), + { status: 403 } + ) + ); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("o3-pro")); + await flush(); + + // Error sent to client + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("forbidden"); + expect((sent?.error as Record)?.message).toBe("Model o3-pro not allowed"); + + // No upstream call + expect(mockExecuteTurn).not.toHaveBeenCalled(); + + // No billing + expect(settleWsTurnBilling).not.toHaveBeenCalled(); + }); + + test("handles guard response with non-JSON body", async () => { + mockPipelineRun.mockResolvedValue(new Response("plain text error", { status: 500 })); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("guard_error"); + expect(mockExecuteTurn).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Provider failure + // ------------------------------------------------------------------------- + + describe("provider selection failure", () => { + test("sends error when no provider is selected", async () => { + vi.mocked(ProxySession.fromWebSocket).mockReturnValue(createMockSession(null) as any); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("server_error"); + expect((sent?.error as Record)?.message).toContain("No provider"); + + expect(mockExecuteTurn).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Transport classified as HTTP + // ------------------------------------------------------------------------- + + describe("transport not websocket", () => { + test("sends explicit error when transport is http", async () => { + vi.mocked(classifyTransport).mockResolvedValue({ + transport: "http", + reason: "provider_type_not_codex", + }); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + const sent = lastSentJson(ws); + expect(sent?.type).toBe("error"); + expect((sent?.error as Record)?.type).toBe("invalid_request_error"); + expect((sent?.error as Record)?.message as string).toContain( + "WebSocket transport not available" + ); + + expect(mockExecuteTurn).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Outbound adapter error + // ------------------------------------------------------------------------- + + describe("outbound adapter error", () => { + test("relays events and settles error on network failure", async () => { + mockBridgeIsSettledRef.value = false; + mockGetSettlement.mockReturnValue({ + status: "error", + eventCount: 2, + durationMs: 500, + errorMessage: "Connection reset", + }); + mockExecuteTurn.mockResolvedValue({ + completed: false, + events: [ + { type: "response.created", data: { type: "response.created" } }, + { type: "response.output_item.added", data: { type: "response.output_item.added" } }, + ], + error: new Error("Connection reset"), + }); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + // Events relayed + expect(mockRelayEvent).toHaveBeenCalledTimes(2); + + // Error settled on bridge + expect(mockSettleError).toHaveBeenCalledWith("Connection reset"); + + // Error sent to client (network error) + const calls = ws.send.mock.calls; + const errorFrame = calls.find((c: unknown[]) => { + const parsed = JSON.parse(c[0] as string) as Record; + return (parsed.error as Record)?.type === "server_error"; + }); + expect(errorFrame).toBeDefined(); + + // Billing still runs (partial billing) + expect(settleWsTurnBilling).toHaveBeenCalled(); + }); + + test("does not double-send error for server error frames", async () => { + mockBridgeIsSettledRef.value = false; + const serverError = { + type: "error", + error: { type: "invalid_request_error", message: "Bad input" }, + }; + mockGetSettlement.mockReturnValue({ + status: "error", + eventCount: 1, + durationMs: 100, + errorMessage: "Bad input", + }); + mockExecuteTurn.mockResolvedValue({ + completed: false, + events: [{ type: "error", data: serverError }], + error: serverError, // ServerErrorFrame, not Error instance + }); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + // Error settled + expect(mockSettleError).toHaveBeenCalled(); + + // No additional sendError (server error was already relayed via bridge) + const sendCalls = ws.send.mock.calls; + const serverErrors = sendCalls.filter((c: unknown[]) => { + const parsed = JSON.parse(c[0] as string) as Record; + return (parsed.error as Record)?.type === "server_error"; + }); + expect(serverErrors).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // Cancel mid-stream + // ------------------------------------------------------------------------- + + describe("cancel mid-stream", () => { + test("closes adapter on cancel during processing", async () => { + // Make executeTurn hang indefinitely until cancelled + let resolveTurn: (value: unknown) => void; + mockExecuteTurn.mockReturnValue( + new Promise((resolve) => { + resolveTurn = resolve; + }) + ); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + // Start turn + ws.emit("message", makeCreateFrame()); + expect(handler.connectionState).toBe("processing"); + + // Let handleTurn advance past adapter creation (needs 2 microtask ticks: + // pipeline.run + classifyTransport, then activeAdapter is set synchronously) + for (let i = 0; i < 5; i++) await Promise.resolve(); + + // Cancel mid-stream (activeAdapter is now set, executeTurn is hanging) + ws.emit("message", makeCancelFrame()); + expect(handler.connectionState).toBe("waiting"); + expect(mockAdapterClose).toHaveBeenCalled(); + + // Resolve the hanging turn to avoid unhandled promise + resolveTurn!({ + completed: false, + events: [], + error: new Error("WebSocket closed unexpectedly: 1000 "), + }); + await flush(); + }); + }); + + // ------------------------------------------------------------------------- + // Terminal event settlement + // ------------------------------------------------------------------------- + + describe("billing settlement", () => { + test("calculates cost with provider cost multiplier", async () => { + const expensiveProvider = { ...validProvider, costMultiplier: 2.5 }; + vi.mocked(ProxySession.fromWebSocket).mockReturnValue( + createMockSession(expensiveProvider) as any + ); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(settleWsTurnBilling).toHaveBeenCalledWith( + expect.objectContaining({ + costMultiplier: 2.5, + }) + ); + }); + + test("maps incomplete status to 200", async () => { + mockGetSettlement.mockReturnValue(makeCompletedSettlement({ status: "incomplete" })); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 42, + expect.objectContaining({ statusCode: 200 }) + ); + }); + + test("maps failed status to 500", async () => { + mockGetSettlement.mockReturnValue(makeCompletedSettlement({ status: "failed" })); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 42, + expect.objectContaining({ statusCode: 500 }) + ); + }); + + test("skips billing when messageContext is null", async () => { + const sessionNoContext = createMockSession(validProvider) as Record; + sessionNoContext.messageContext = null; + vi.mocked(ProxySession.fromWebSocket).mockReturnValue(sessionNoContext as any); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + expect(settleWsTurnBilling).not.toHaveBeenCalled(); + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + }); + + test("billing error does not fail the turn", async () => { + vi.mocked(updateMessageRequestCost).mockRejectedValue(new Error("DB connection lost")); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + // Turn still completes successfully + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // Sequential turns + // ------------------------------------------------------------------------- + + describe("sequential turns", () => { + test("second turn works after first settles", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + // Turn 1 + ws.emit("message", makeCreateFrame("gpt-4o")); + await flush(); + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(1); + + // Turn 2 + ws.emit("message", makeCreateFrame("o3-pro")); + await flush(); + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(2); + + // Both turns used the pipeline + expect(mockPipelineRun).toHaveBeenCalledTimes(2); + expect(mockExecuteTurn).toHaveBeenCalledTimes(2); + }); + + test("second turn after guard rejection works", async () => { + // First turn: guard rejects + mockPipelineRun.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { type: "forbidden", message: "Not allowed" } }), { + status: 403, + }) + ); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame("bad-model")); + await flush(); + expect(handler.connectionState).toBe("waiting"); + + // Second turn: guard passes + mockPipelineRun.mockResolvedValueOnce(null); + ws.emit("message", makeCreateFrame("gpt-4o")); + await flush(); + expect(handler.connectionState).toBe("waiting"); + expect(handler.completedTurns).toBe(2); + expect(mockExecuteTurn).toHaveBeenCalledTimes(1); // Only second turn reached adapter + }); + }); + + // ------------------------------------------------------------------------- + // activeAdapter cleanup + // ------------------------------------------------------------------------- + + describe("activeAdapter lifecycle", () => { + test("activeAdapter is cleared after successful turn", async () => { + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + // Cancel after turn completes should not call close (adapter already null) + mockAdapterClose.mockClear(); + ws.emit("message", makeCancelFrame()); + expect(mockAdapterClose).not.toHaveBeenCalled(); + }); + + test("activeAdapter is cleared even on error", async () => { + mockExecuteTurn.mockRejectedValue(new Error("unexpected error")); + + const ws = createMockWs(); + const handler = new WsIngressHandler(ws as any, createMockReq()); + await handler.start(); + + ws.emit("message", makeCreateFrame()); + await flush(); + + // Adapter should be cleaned up via finally + mockAdapterClose.mockClear(); + ws.emit("message", makeCancelFrame()); + expect(mockAdapterClose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/ws/ingress-handler.test.ts b/tests/unit/ws/ingress-handler.test.ts index 4c90023fb..123171ed8 100644 --- a/tests/unit/ws/ingress-handler.test.ts +++ b/tests/unit/ws/ingress-handler.test.ts @@ -19,6 +19,36 @@ vi.mock("@/app/v1/_lib/proxy/auth-guard", () => ({ extractApiKeyFromHeaders: vi.fn(), })); +// Mock handleTurn dependencies (lifecycle tests don't exercise the full pipeline; +// these stubs prevent real DB/network imports from executing) +vi.mock("@/app/v1/_lib/proxy/session", () => ({ + ProxySession: { fromWebSocket: vi.fn() }, +})); +vi.mock("@/app/v1/_lib/proxy/guard-pipeline", () => ({ + GuardPipelineBuilder: { build: vi.fn() }, +})); +vi.mock("@/app/v1/_lib/proxy/transport-classifier", () => ({ + classifyTransport: vi.fn(), +})); +vi.mock("@/app/v1/_lib/ws/outbound-adapter", () => ({ + OutboundWsAdapter: vi.fn(), +})); +vi.mock("@/app/v1/_lib/ws/event-bridge", () => ({ + WsEventBridge: vi.fn(), +})); +vi.mock("@/app/v1/_lib/ws/billing-parity", () => ({ + settleWsTurnBilling: vi.fn(), + buildWsTraceMetadata: vi.fn(), +})); +vi.mock("@/app/v1/_lib/ws/session-continuity", () => ({ + createWsTurnContext: vi.fn(), + updateSessionFromTerminal: vi.fn(), +})); +vi.mock("@/repository/message", () => ({ + updateMessageRequestCost: vi.fn(), + updateMessageRequestDetails: vi.fn(), +})); + vi.mock("@/lib/logger", () => ({ logger: { debug: vi.fn(),