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/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/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/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/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/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/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-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/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/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/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/[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/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts
index 67afc2ef5..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 是否被过滤器修改过。
*
@@ -451,6 +514,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/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/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..ac9898d1d
--- /dev/null
+++ b/src/app/v1/_lib/ws/ingress-handler.ts
@@ -0,0 +1,532 @@
+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 { 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
+// ---------------------------------------------------------------------------
+
+/** 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;
+ private activeAdapter: OutboundWsAdapter | null = null;
+
+ 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 });
+ if (this.activeAdapter) {
+ this.activeAdapter.close();
+ this.activeAdapter = null;
+ }
+ 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.
+ *
+ * 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 {
+ 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 */
+ 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/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/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/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/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/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();
+ });
+});
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();
+ });
+});
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-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
new file mode 100644
index 000000000..123171ed8
--- /dev/null
+++ b/tests/unit/ws/ingress-handler.test.ts
@@ -0,0 +1,627 @@
+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(),
+}));
+
+// 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(),
+ 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);
+ });
+ });
+});