From 19b1f9f3bdb83b4bafd400375a291e2fd04a440d Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 25 Apr 2026 09:18:39 +0800 Subject: [PATCH 01/47] feat(proxy): add OpenAI Responses WebSocket support for Codex providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `enableOpenaiResponsesWebsocket` system setting (default on) that lets clients connect to `/v1/responses` over WebSocket. CCH accepts the upgrade via a new custom Node server that wraps the Next.js handler, then tunnels each client `response.create` frame through the existing HTTP proxy pipeline using an `x-cch-client-transport: websocket` marker. For Codex providers — and only Codex — the forwarder pre-flights an upstream WebSocket dial; on handshake rejection or close-before-first-event it gracefully falls back to the existing HTTP path while keeping the client WebSocket open. Fallbacks are recorded on the decision chain (`responses_ws_attempted` / `responses_ws_fallback`) and do NOT count toward provider/endpoint/vendor circuit-breaker accounting, mirroring the existing `http2_fallback` isolation pattern. Non-WebSocket clients, non-Codex providers, and all existing HTTP/SSE behavior are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- drizzle/0099_worthless_gauntlet.sql | 1 + drizzle/meta/0099_snapshot.json | 4478 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- messages/en/settings/config.json | 2 + messages/ja/settings/config.json | 2 + messages/ru/settings/config.json | 2 + messages/zh-CN/settings/config.json | 2 + messages/zh-TW/settings/config.json | 2 + next.config.ts | 9 +- package.json | 7 +- scripts/copy-custom-server-to-standalone.cjs | 31 + server.js | 405 ++ src/actions/system-config.ts | 2 + .../_components/system-settings-form.tsx | 30 + src/app/[locale]/settings/config/page.tsx | 1 + src/app/api/admin/system-config/route.ts | 1 + src/app/v1/_lib/proxy/forwarder.ts | 100 +- src/app/v1/_lib/proxy/session.ts | 2 + .../__tests__/eligibility.test.ts | 142 + .../__tests__/unsupported-cache.test.ts | 47 + .../__tests__/upstream-adapter.test.ts | 197 + src/app/v1/_lib/responses-ws/eligibility.ts | 87 + .../v1/_lib/responses-ws/unsupported-cache.ts | 53 + .../v1/_lib/responses-ws/upstream-adapter.ts | 357 ++ src/drizzle/schema.ts | 7 + src/lib/config/system-settings-cache.ts | 14 + src/lib/validation/schemas.ts | 2 + src/repository/_shared/transformers.test.ts | 1 + src/repository/_shared/transformers.ts | 1 + src/repository/system-config.ts | 74 +- src/types/message.ts | 22 + src/types/system-config.ts | 8 + 32 files changed, 6079 insertions(+), 19 deletions(-) create mode 100644 drizzle/0099_worthless_gauntlet.sql create mode 100644 drizzle/meta/0099_snapshot.json create mode 100644 scripts/copy-custom-server-to-standalone.cjs create mode 100644 server.js create mode 100644 src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts create mode 100644 src/app/v1/_lib/responses-ws/__tests__/unsupported-cache.test.ts create mode 100644 src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts create mode 100644 src/app/v1/_lib/responses-ws/eligibility.ts create mode 100644 src/app/v1/_lib/responses-ws/unsupported-cache.ts create mode 100644 src/app/v1/_lib/responses-ws/upstream-adapter.ts diff --git a/drizzle/0099_worthless_gauntlet.sql b/drizzle/0099_worthless_gauntlet.sql new file mode 100644 index 000000000..cc8752e2a --- /dev/null +++ b/drizzle/0099_worthless_gauntlet.sql @@ -0,0 +1 @@ +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "enable_openai_responses_websocket" boolean DEFAULT true NOT NULL; diff --git a/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json new file mode 100644 index 000000000..6a6e020de --- /dev/null +++ b/drizzle/meta/0099_snapshot.json @@ -0,0 +1,4478 @@ +{ + "id": "f475eec8-aa3e-4c45-8b70-85400b73a776", + "prevId": "6014bb32-638d-4ca1-bb4b-16d9f3fe0e01", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "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 + }, + "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 + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "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\".\"status_code\" IS NOT NULL", + "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": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "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\".\"client_ip\" IS NOT 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_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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(255)", + "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 + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "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": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "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'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "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 + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "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_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "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 + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": 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 + }, + "group_cost_multiplier": { + "name": "group_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_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 + }, + "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 579a42619..d803298a3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1776965161943, "tag": "0098_equal_selene", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1777047297106, + "tag": "0099_worthless_gauntlet", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 0a102133b..9afe4f0cc 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -56,6 +56,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.", + "enableOpenaiResponsesWebsocket": "Enable OpenAI Responses WebSocket", + "enableOpenaiResponsesWebsocketDesc": "When enabled, if a client opens a WebSocket connection to /v1/responses and the selected provider is a Codex type, CCH will attempt a sibling WebSocket to the upstream. If the upstream does not support WebSocket or the handshake fails, CCH gracefully falls back to standard HTTP Responses while keeping the client WebSocket open; the fallback is not counted toward circuit breakers. Non-WebSocket clients and non-Codex providers are unaffected.", "enableHighConcurrencyMode": "Enable High-Concurrency Mode", "enableHighConcurrencyModeDesc": "When enabled, CCH disables part of the Redis debug snapshots and real-time session observability writes to reduce CPU and IO pressure under high RPM. Forwarding, rectifiers, fake-200 detection, billing, and quota enforcement remain unchanged, but Sessions debugging details may be reduced or delayed.", "enableResponseFixer": "Enable Response Fixer", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index bc47d2580..6560a4280 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -56,6 +56,8 @@ "enableAutoCleanupDesc": "スケジュールに従って履歴ログを自動的にクリーンアップします", "enableHttp2": "HTTP/2 を有効にする", "enableHttp2Desc": "有効にすると、プロキシ要求は優先的に HTTP/2 を使用します。HTTP/2 が失敗した場合は自動的に HTTP/1.1 にフォールバックします。", + "enableOpenaiResponsesWebsocket": "OpenAI Responses WebSocket を有効化", + "enableOpenaiResponsesWebsocketDesc": "有効にすると、クライアントが /v1/responses に WebSocket 接続し、かつ Codex タイプのプロバイダーが選択された場合、CCH は上流にも WebSocket 接続を試みます。上流が WebSocket をサポートしない、またはハンドシェイクに失敗した場合は、クライアント WebSocket を開いたまま通常の HTTP Responses に優雅にフォールバックします。このフォールバックはサーキットブレーカーにカウントされません。非 WebSocket クライアントと非 Codex プロバイダーの動作は変わりません。", "enableHighConcurrencyMode": "高並行モードを有効化", "enableHighConcurrencyModeDesc": "有効にすると、高 RPM 時の CPU / IO 負荷を下げるため、Redis の一部デバッグスナップショットとリアルタイム Session 観測書き込みを停止します。転送、整流、fake 200 検知、課金、制限処理は維持されますが、Sessions のデバッグ詳細は減少または遅延する場合があります。", "enableResponseFixer": "レスポンス整流を有効化", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index d6920235a..8945d16ba 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -56,6 +56,8 @@ "enableAutoCleanupDesc": "Автоматически очищать исторические логи по расписанию", "enableHttp2": "Включить HTTP/2", "enableHttp2Desc": "При включении прокси-запросы будут отдавать приоритет HTTP/2. Если HTTP/2 не удастся, произойдёт автоматическое понижение до HTTP/1.1.", + "enableOpenaiResponsesWebsocket": "Включить OpenAI Responses WebSocket", + "enableOpenaiResponsesWebsocketDesc": "Если включено, то когда клиент открывает WebSocket-соединение с /v1/responses и выбирается провайдер типа Codex, CCH попытается установить WebSocket-соединение с вышестоящим сервером. Если сервер не поддерживает WebSocket или рукопожатие не удастся, CCH плавно переключится на обычный HTTP Responses, сохраняя WebSocket клиента открытым; этот fallback не учитывается в circuit breaker. Клиенты без WebSocket и провайдеры, отличные от Codex, работают без изменений.", "enableHighConcurrencyMode": "Включить режим высокой нагрузки", "enableHighConcurrencyModeDesc": "Если включено, CCH отключит часть Redis-снимков для отладки и записи real-time Session-наблюдения, чтобы снизить нагрузку на CPU и IO при высоком RPM. Пересылка, rectifier-логика, обнаружение fake 200, биллинг и лимиты сохраняются, но детализация отладки в Sessions может уменьшиться или запаздывать.", "enableResponseFixer": "Включить исправление ответов", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 8b95c5bdf..7a3602791 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -48,6 +48,8 @@ "passThroughUpstreamErrorMessageDesc": "开启后,CCH 会在能提取出安全脱敏后的上游错误消息时替换 `error.message`;否则 `error.message` 回退到通用代理错误。它不会关闭“详细供应商错误信息”控制的 `error.details` 诊断。", "enableHttp2": "启用 HTTP/2", "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", + "enableOpenaiResponsesWebsocket": "启用 OpenAI Responses WebSocket", + "enableOpenaiResponsesWebsocketDesc": "启用后,当客户端以 WebSocket 连接 /v1/responses 且选中 Codex 类型供应商时,CCH 会尝试与上游建立 WebSocket。若上游不支持或握手失败,将优雅降级到普通 HTTP Responses,客户端 WebSocket 保持打开;降级不计入熔断。非 WebSocket 客户端与非 Codex 供应商行为不变。", "enableHighConcurrencyMode": "启用高并发模式", "enableHighConcurrencyModeDesc": "开启后,将关闭部分 Redis 调试快照与实时会话观测写入,以降低高并发下的 CPU 与 IO 开销。不会影响转发、整流、fake 200 检测、计费与限额,但 Sessions 调试详情会减少或延后。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index cfeb43fd2..c4e604235 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -56,6 +56,8 @@ "enableAutoCleanupDesc": "定時自動清理歷史日誌資料", "enableHttp2": "啟用 HTTP/2", "enableHttp2Desc": "啟用後,代理請求將優先使用 HTTP/2 協定;若 HTTP/2 失敗,將自動降級為 HTTP/1.1。", + "enableOpenaiResponsesWebsocket": "啟用 OpenAI Responses WebSocket", + "enableOpenaiResponsesWebsocketDesc": "啟用後,當客戶端以 WebSocket 連線 /v1/responses 且命中 Codex 類型供應商時,CCH 會嘗試與上游建立 WebSocket 連線。若上游不支援或握手失敗,將優雅降級為一般 HTTP Responses,客戶端 WebSocket 保持開啟;降級不計入熔斷。非 WebSocket 客戶端與非 Codex 供應商行為不變。", "enableHighConcurrencyMode": "啟用高並發模式", "enableHighConcurrencyModeDesc": "開啟後,將關閉部分 Redis 除錯快照與即時 Session 觀測寫入,以降低高並發下的 CPU 與 IO 開銷。轉發、整流、fake 200 偵測、計費與限額不受影響,但 Sessions 除錯詳情會減少或延後。", "enableResponseFixer": "啟用回應整流", diff --git a/next.config.ts b/next.config.ts index f3e726bfd..01769dd4c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -27,7 +27,14 @@ const nextConfig: NextConfig = { // 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 服务器使用 next 的 programmatic API 与 ws 处理 WebSocket 升级, + // 需要强制追踪这两个包到 standalone 输出。 + "./node_modules/next/**/*", + "./node_modules/ws/**/*", + ], }, // 文件上传大小限制(用于数据库备份导入) diff --git a/package.json b/package.json index 8a5ffff7d..ed71a5d92 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "private": true, "scripts": { "dev": "tsgo -p tsconfig.json --noEmit && next dev --port 13500", - "build": "tsgo -p tsconfig.json --noEmit && next build && (node scripts/copy-version-to-standalone.cjs || bun scripts/copy-version-to-standalone.cjs)", - "start": "next start", + "dev:server": "NODE_ENV=development node server.js", + "build": "tsgo -p tsconfig.json --noEmit && next build && (node scripts/copy-version-to-standalone.cjs || bun scripts/copy-version-to-standalone.cjs) && (node scripts/copy-custom-server-to-standalone.cjs || bun scripts/copy-custom-server-to-standalone.cjs)", + "start": "node server.js", "lint": "biome check .", "lint:fix": "biome check --write .", "typecheck": "tsgo -p tsconfig.json --noEmit", @@ -111,6 +112,7 @@ "tw-animate-css": "^1", "undici": "^7", "vaul": "^1", + "ws": "^8", "zod": "^4" }, "devDependencies": { @@ -122,6 +124,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15", + "@types/ws": "^8", "@typescript/native-preview": "7.0.0-dev.20260321.1", "@vitest/coverage-v8": "^4", "@vitest/ui": "^4", diff --git a/scripts/copy-custom-server-to-standalone.cjs b/scripts/copy-custom-server-to-standalone.cjs new file mode 100644 index 000000000..0ed7422eb --- /dev/null +++ b/scripts/copy-custom-server-to-standalone.cjs @@ -0,0 +1,31 @@ +/** + * Copy the custom Node server (with WebSocket upgrade support) into the + * Next.js standalone output, overwriting the generated server.js so Docker + * runtime (`CMD ["node", "server.js"]`) boots the custom one instead. + * + * The generated standalone server.js is the default Next.js minimal server; + * ours wraps Next.js programmatically plus adds WebSocket upgrade handling + * on /v1/responses. See server.js at the repo root for the full rationale. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const src = path.resolve(process.cwd(), "server.js"); +const dstDir = path.resolve(process.cwd(), ".next", "standalone"); +const dst = path.join(dstDir, "server.js"); + +if (!fs.existsSync(src)) { + console.error(`[copy-custom-server] Custom server not found at ${src}`); + process.exit(1); +} + +if (!fs.existsSync(dstDir)) { + console.warn( + `[copy-custom-server] Standalone output dir missing at ${dstDir}; skipping (did next build run?)` + ); + process.exit(0); +} + +fs.copyFileSync(src, dst); +console.log(`[copy-custom-server] Copied ${src} -> ${dst}`); diff --git a/server.js b/server.js new file mode 100644 index 000000000..fbc4a5ef1 --- /dev/null +++ b/server.js @@ -0,0 +1,405 @@ +// Custom Node.js server for claude-code-hub. +// +// Purpose: add WebSocket upgrade support on /v1/responses so clients that speak +// the OpenAI Responses WebSocket protocol (text JSON frames with +// type=response.create) can proxy through CCH. All other HTTP traffic is +// delegated to the Next.js App Router handler unchanged. +// +// Architecture: this server is a thin tunnel. For each client WebSocket frame, +// we build an equivalent HTTP POST against the same app's /v1/responses +// endpoint (with an x-cch-client-transport header) so that auth, provider +// selection, guard pipeline, forwarder, circuit breakers, observability, and +// all existing TypeScript business logic run exactly once. Upstream WebSocket +// attempts live inside that TypeScript pipeline (forwarder), not here. +// +// Compatibility: +// - Non-WebSocket clients: unaffected. HTTP still flows through Next.js. +// - Non-Codex providers: the forwarder never attempts upstream WS; client WS +// is still accepted and tunneled through HTTP SSE. +// - Setting disabled: client WS handshake still succeeds (so clients don't +// break), but every frame is tunneled over HTTP with no upstream-WS attempt. + +"use strict"; + +const http = require("node:http"); +const { parse } = require("node:url"); + +const dev = process.env.NODE_ENV !== "production"; +const hostname = process.env.HOSTNAME || "0.0.0.0"; +const port = parseInt(process.env.PORT || (dev ? "13500" : "3000"), 10); + +const WS_PATH = "/v1/responses"; +const CLIENT_TRANSPORT_HEADER = "x-cch-client-transport"; +const WS_FORWARD_FLAG_HEADER = "x-cch-responses-ws-forward"; + +const TERMINAL_EVENT_TYPES = new Set([ + "response.completed", + "response.failed", + "response.incomplete", + "error", +]); + +function log(level, msg, extra) { + const line = { ts: new Date().toISOString(), level, msg, ...(extra || {}) }; + try { + process.stdout.write(`${JSON.stringify(line)}\n`); + } catch { + // ignore + } +} + +function safeSend(ws, data) { + try { + if (ws.readyState === 1 /* OPEN */) { + ws.send(typeof data === "string" ? data : JSON.stringify(data)); + return true; + } + } catch (err) { + log("warn", "ws_send_failed", { error: String(err) }); + } + return false; +} + +function emitErrorEvent(ws, code, message) { + safeSend(ws, { + type: "error", + error: { code, message }, + }); +} + +async function handleWebSocketConnection(ws, req) { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const queryModel = url.searchParams.get("model"); + let inFlight = false; + const pending = []; + let closed = false; + + const finalize = () => { + closed = true; + }; + ws.on("close", finalize); + ws.on("error", finalize); + + const processFrame = async (raw) => { + if (closed) return; + + if (typeof raw !== "string") { + emitErrorEvent(ws, "invalid_frame_type", "Only text WebSocket frames are supported"); + try { + ws.close(1003, "binary_not_supported"); + } catch { + // ignore + } + return; + } + + let frame; + try { + frame = JSON.parse(raw); + } catch (err) { + emitErrorEvent(ws, "invalid_json", `Invalid JSON frame: ${err && err.message ? err.message : "parse error"}`); + return; + } + + if (!frame || typeof frame !== "object") { + emitErrorEvent(ws, "invalid_frame", "Frame must be a JSON object"); + return; + } + + if (frame.type !== "response.create") { + emitErrorEvent(ws, "unsupported_event_type", `Only type=response.create is supported; received: ${frame.type ?? "(missing)"}`); + return; + } + + const { type, ...rawBody } = frame; + const body = { ...rawBody }; + // body.model wins over query; only fill from query when body lacks a model + // (LiteLLM/other compat). Drop transport-only fields. + if (queryModel && (body.model === undefined || body.model === null || body.model === "")) { + body.model = queryModel; + } + + await forwardToInternalHttp(ws, req, body); + }; + + const drain = async () => { + if (inFlight) return; + const next = pending.shift(); + if (!next) return; + inFlight = true; + try { + await processFrame(next); + } finally { + inFlight = false; + if (pending.length > 0 && !closed) { + void drain(); + } + } + }; + + ws.on("message", (data, isBinary) => { + if (closed) return; + if (isBinary) { + emitErrorEvent(ws, "invalid_frame_type", "Only text WebSocket frames are supported"); + try { + ws.close(1003, "binary_not_supported"); + } catch { + // ignore + } + return; + } + pending.push(data.toString("utf8")); + void drain(); + }); +} + +async function forwardToInternalHttp(ws, originalReq, body) { + const internalHeaders = {}; + for (const [k, v] of Object.entries(originalReq.headers)) { + // Skip hop-by-hop / WS-specific headers; keep app-level auth/session etc. + const lower = k.toLowerCase(); + if ( + lower === "host" || + lower === "connection" || + lower === "upgrade" || + lower === "sec-websocket-key" || + lower === "sec-websocket-version" || + lower === "sec-websocket-extensions" || + lower === "sec-websocket-protocol" || + lower === "content-length" || + lower === "transfer-encoding" + ) { + continue; + } + if (Array.isArray(v)) { + internalHeaders[k] = v.join(", "); + } else if (typeof v === "string") { + internalHeaders[k] = v; + } + } + internalHeaders["accept"] = "text/event-stream"; + internalHeaders["content-type"] = "application/json"; + internalHeaders[CLIENT_TRANSPORT_HEADER] = "websocket"; + internalHeaders[WS_FORWARD_FLAG_HEADER] = "1"; + + // Force streaming so we can translate SSE events to WS frames incrementally. + // The upstream pipeline will strip transport-only fields (stream, background) + // before forwarding to upstream WebSocket. + const bodyForHttp = { ...body, stream: true }; + delete bodyForHttp.background; + + const payload = Buffer.from(JSON.stringify(bodyForHttp), "utf8"); + internalHeaders["content-length"] = String(payload.length); + + await new Promise((resolve) => { + const req = http.request( + { + method: "POST", + hostname: "127.0.0.1", + port, + path: "/v1/responses", + headers: internalHeaders, + }, + (res) => { + const contentType = (res.headers["content-type"] || "").toLowerCase(); + const isSse = contentType.includes("text/event-stream"); + + if (!isSse) { + // Upstream returned non-stream JSON (e.g. error response). Collect + // and emit as a single terminal event. + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = { raw: text }; + } + if (res.statusCode && res.statusCode >= 400) { + safeSend(ws, { + type: "error", + error: typeof parsed === "object" && parsed && parsed.error + ? parsed.error + : { code: `http_${res.statusCode}`, message: text.slice(0, 512) }, + }); + } else { + safeSend(ws, { + type: "response.completed", + response: parsed, + }); + } + resolve(); + }); + res.on("error", (err) => { + emitErrorEvent(ws, "internal_response_error", String(err && err.message ? err.message : err)); + resolve(); + }); + return; + } + + // SSE path: decode `data:` events and emit each as a WS JSON frame. + let buffer = ""; + let sawTerminal = false; + + const flushEvents = () => { + let idx; + while ((idx = buffer.indexOf("\n\n")) !== -1) { + const chunk = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const lines = chunk.split(/\r?\n/); + const dataLines = []; + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + if (dataLines.length === 0) continue; + const dataText = dataLines.join("\n"); + if (dataText === "[DONE]") { + if (!sawTerminal) { + // Some upstreams close SSE with [DONE] without a preceding + // response.completed. Synthesize one so the client sees a + // clean terminal event. + safeSend(ws, { type: "response.completed", response: null }); + sawTerminal = true; + } + continue; + } + let event; + try { + event = JSON.parse(dataText); + } catch { + // Not JSON; forward as raw string event. + safeSend(ws, { type: "response.output_text.delta", delta: dataText }); + continue; + } + safeSend(ws, event); + if (event && typeof event.type === "string" && TERMINAL_EVENT_TYPES.has(event.type)) { + sawTerminal = true; + } + } + }; + + res.setEncoding("utf8"); + res.on("data", (chunk) => { + buffer += chunk; + flushEvents(); + }); + res.on("end", () => { + // Flush any remaining buffered event + if (buffer.trim().length > 0) { + buffer += "\n\n"; + flushEvents(); + } + if (!sawTerminal) { + emitErrorEvent( + ws, + "stream_ended_without_terminal", + "Upstream stream ended before emitting a terminal response event" + ); + } + resolve(); + }); + res.on("error", (err) => { + emitErrorEvent(ws, "internal_response_error", String(err && err.message ? err.message : err)); + resolve(); + }); + } + ); + + req.on("error", (err) => { + emitErrorEvent(ws, "internal_request_error", String(err && err.message ? err.message : err)); + resolve(); + }); + + req.write(payload); + req.end(); + }); +} + +function isResponsesWsUpgrade(req) { + if (!req.url) return false; + const parsed = parse(req.url); + return parsed.pathname === WS_PATH; +} + +async function main() { + // Import Next programmatically. We require it lazily so that the server can + // still report a clean error if Next is not installed (unlikely but possible + // in a misconfigured deployment). + let nextModule; + try { + // eslint-disable-next-line global-require + nextModule = require("next"); + } catch (err) { + log("error", "next_import_failed", { error: String(err && err.message ? err.message : err) }); + process.exit(1); + return; + } + const nextFactory = typeof nextModule === "function" ? nextModule : nextModule.default; + + let WebSocketServer; + try { + // eslint-disable-next-line global-require + WebSocketServer = require("ws").WebSocketServer; + } catch (err) { + log("warn", "ws_module_unavailable_ws_disabled", { + error: String(err && err.message ? err.message : err), + }); + WebSocketServer = null; + } + + const app = nextFactory({ dev, hostname, port }); + const handler = app.getRequestHandler(); + await app.prepare(); + + const server = http.createServer(async (req, res) => { + try { + const parsedUrl = parse(req.url, true); + await handler(req, res, parsedUrl); + } catch (err) { + log("error", "http_handler_error", { error: String(err && err.message ? err.message : err) }); + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } + } + }); + + if (WebSocketServer) { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + if (!isResponsesWsUpgrade(req)) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + log("info", "ws_client_connected", { path: req.url }); + handleWebSocketConnection(ws, req).catch((err) => { + log("error", "ws_handler_error", { error: String(err && err.message ? err.message : err) }); + try { + ws.close(1011, "internal_error"); + } catch { + // ignore + } + }); + }); + }); + } else { + server.on("upgrade", (_req, socket) => { + socket.destroy(); + }); + } + + server.listen(port, hostname, () => { + log("info", "server_listening", { hostname, port, wsEnabled: !!WebSocketServer }); + }); +} + +main().catch((err) => { + log("error", "server_bootstrap_failed", { error: String(err && err.stack ? err.stack : err) }); + process.exit(1); +}); diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 65f711843..1c4eb718d 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -65,6 +65,7 @@ export async function saveSystemSettings(formData: { verboseProviderError?: boolean; passThroughUpstreamErrorMessage?: boolean; enableHttp2?: boolean; + enableOpenaiResponsesWebsocket?: boolean; enableHighConcurrencyMode?: boolean; interceptAnthropicWarmupRequests?: boolean; enableThinkingSignatureRectifier?: boolean; @@ -113,6 +114,7 @@ export async function saveSystemSettings(formData: { verboseProviderError: validated.verboseProviderError, passThroughUpstreamErrorMessage: validated.passThroughUpstreamErrorMessage, enableHttp2: validated.enableHttp2, + enableOpenaiResponsesWebsocket: validated.enableOpenaiResponsesWebsocket, enableHighConcurrencyMode: validated.enableHighConcurrencyMode, interceptAnthropicWarmupRequests: validated.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier, 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 c60180fff..48f2f4610 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 { MapPin, Network, Pencil, + Radio, Terminal, Thermometer, Wrench, @@ -64,6 +65,7 @@ interface SystemSettingsFormProps { | "verboseProviderError" | "passThroughUpstreamErrorMessage" | "enableHttp2" + | "enableOpenaiResponsesWebsocket" | "enableHighConcurrencyMode" | "interceptAnthropicWarmupRequests" | "enableThinkingSignatureRectifier" @@ -125,6 +127,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) initialSettings.passThroughUpstreamErrorMessage ); const [enableHttp2, setEnableHttp2] = useState(initialSettings.enableHttp2); + const [enableOpenaiResponsesWebsocket, setEnableOpenaiResponsesWebsocket] = useState( + initialSettings.enableOpenaiResponsesWebsocket + ); const [enableHighConcurrencyMode, setEnableHighConcurrencyMode] = useState( initialSettings.enableHighConcurrencyMode ); @@ -244,6 +249,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) verboseProviderError, passThroughUpstreamErrorMessage, enableHttp2, + enableOpenaiResponsesWebsocket, enableHighConcurrencyMode, interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier, @@ -280,6 +286,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setVerboseProviderError(result.data.verboseProviderError); setPassThroughUpstreamErrorMessage(result.data.passThroughUpstreamErrorMessage); setEnableHttp2(result.data.enableHttp2); + setEnableOpenaiResponsesWebsocket(result.data.enableOpenaiResponsesWebsocket); setEnableHighConcurrencyMode(result.data.enableHighConcurrencyMode); setInterceptAnthropicWarmupRequests(result.data.interceptAnthropicWarmupRequests); setEnableThinkingSignatureRectifier(result.data.enableThinkingSignatureRectifier); @@ -554,6 +561,29 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) /> + {/* Enable OpenAI Responses WebSocket (Codex only) */} +
+
+
+ +
+
+

+ {t("enableOpenaiResponsesWebsocket")} +

+

+ {t("enableOpenaiResponsesWebsocketDesc")} +

+
+
+ setEnableOpenaiResponsesWebsocket(checked)} + disabled={isPending} + /> +
+
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index bbe54df4b..0c3b1106a 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -49,6 +49,7 @@ async function SettingsConfigContent() { verboseProviderError: settings.verboseProviderError, passThroughUpstreamErrorMessage: settings.passThroughUpstreamErrorMessage, enableHttp2: settings.enableHttp2, + enableOpenaiResponsesWebsocket: settings.enableOpenaiResponsesWebsocket, enableHighConcurrencyMode: settings.enableHighConcurrencyMode, interceptAnthropicWarmupRequests: settings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier, diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index bcf3be468..93cc54a68 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -71,6 +71,7 @@ export async function POST(req: Request) { verboseProviderError: validated.verboseProviderError, passThroughUpstreamErrorMessage: validated.passThroughUpstreamErrorMessage, enableHttp2: validated.enableHttp2, + enableOpenaiResponsesWebsocket: validated.enableOpenaiResponsesWebsocket, enableHighConcurrencyMode: validated.enableHighConcurrencyMode, interceptAnthropicWarmupRequests: validated.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier, diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 1e97622db..6b3808e8e 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -47,6 +47,9 @@ import type { import { GeminiAuth } from "../gemini/auth"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; import { HeaderProcessor, resolveAnthropicAuthHeaders } from "../headers"; +import { evaluateResponsesWsEligibility } from "../responses-ws/eligibility"; +import { markResponsesWsUnsupported } from "../responses-ws/unsupported-cache"; +import { tryResponsesWebsocketUpstream } from "../responses-ws/upstream-adapter"; import { buildProxyUrl } from "../url"; import { rectifyBillingHeader } from "./billing-header-rectifier"; import { deriveClientSafeUpstreamErrorMessage } from "./client-error-message"; @@ -2817,19 +2820,96 @@ export class ProxyForwarder { (init as Record).verbose = true; + // ⭐ OpenAI Responses WebSocket 上游尝试(仅 Codex 供应商 + 开关开启 + 客户端以 WS 接入) + // 若握手失败或首帧前关闭,降级到下面的 HTTP 路径;不计入熔断器。 + let responsesWsResponse: Response | null = null; + try { + const wsEligibility = await evaluateResponsesWsEligibility({ + headers: processedHeaders, + provider, + endpointId: null, + }); + + if (wsEligibility.eligible) { + const requestMessage = session.request.message; + const requestBodyJson = + requestMessage && typeof requestMessage === "object" + ? (requestMessage as Record) + : null; + + if (requestBodyJson) { + const wsResult = await tryResponsesWebsocketUpstream({ + provider, + upstreamUrl: proxyUrl, + upstreamHeaders: processedHeaders, + body: requestBodyJson, + abortSignal: combinedSignal, + }); + + if ("response" in wsResult) { + responsesWsResponse = wsResult.response; + logger.info("ProxyForwarder: Upstream Responses WebSocket connected", { + providerId: provider.id, + providerName: provider.name, + connected: wsResult.connected, + }); + session.addProviderToChain(provider, { + reason: "responses_ws_attempted", + attemptNumber: undefined, + }); + } else { + markResponsesWsUnsupported(provider.id, null, wsResult.reason); + logger.info( + "ProxyForwarder: Upstream Responses WebSocket unavailable, falling back to HTTP", + { + providerId: provider.id, + providerName: provider.name, + reason: wsResult.reason, + message: wsResult.message, + } + ); + session.addProviderToChain(provider, { + reason: "responses_ws_fallback", + errorMessage: wsResult.message, + attemptNumber: undefined, + }); + } + } + } else if (wsEligibility.isWebsocketClient && wsEligibility.downgradeReason) { + session.addProviderToChain(provider, { + reason: "responses_ws_fallback", + errorMessage: wsEligibility.downgradeReason, + attemptNumber: undefined, + }); + } + } catch (wsError) { + logger.warn( + "ProxyForwarder: Upstream Responses WebSocket attempt threw, falling back to HTTP", + { + providerId: provider.id, + providerName: provider.name, + error: String( + wsError && (wsError as Error).message ? (wsError as Error).message : wsError + ), + } + ); + } + // ⭐ 所有供应商使用 undici.request 绕过 fetch 的自动解压 // 原因:undici fetch 无法关闭自动解压,上游可能无视 accept-encoding: identity 返回 gzip // 当 gzip 流被提前终止时(如连接关闭),undici Gunzip 会抛出 "TypeError: terminated" - response = useErrorTolerantFetch - ? await ProxyForwarder.fetchWithoutAutoDecode( - proxyUrl, - init, - provider.id, - provider.name, - session, - deferDetailSnapshotPersistence - ) - : await fetch(proxyUrl, init); + response = responsesWsResponse + ? responsesWsResponse + : useErrorTolerantFetch + ? await ProxyForwarder.fetchWithoutAutoDecode( + proxyUrl, + init, + provider.id, + provider.name, + session, + deferDetailSnapshotPersistence + ) + : await fetch(proxyUrl, init); // ⭐ fetch 成功:收到 HTTP 响应头,保留响应超时继续监控 // 注意:undici 的 fetch 在收到 HTTP 响应头后就 resolve,但实际数据(SSE 首字节 / 完整 JSON) // 还没到达。responseTimeoutId 需要延续到 response-handler 中才能真正控制"首字节"或"总耗时" diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 5e892c1c7..a2b14a2a1 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -546,6 +546,8 @@ export class ProxySession { | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) + | "responses_ws_attempted" // 已尝试上游 OpenAI Responses WebSocket 建连(信息性记录) + | "responses_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/responses-ws/__tests__/eligibility.test.ts b/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts new file mode 100644 index 000000000..720a217f5 --- /dev/null +++ b/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Provider } from "@/types/provider"; +import { + CLIENT_TRANSPORT_HEADER, + evaluateResponsesWsEligibility, + isWebsocketClientRequest, +} from "../eligibility"; +import { clearResponsesWsUnsupportedCache, markResponsesWsUnsupported } from "../unsupported-cache"; + +const isOpenaiResponsesWebsocketEnabledMock = vi.fn(); +vi.mock("@/lib/config/system-settings-cache", () => ({ + isOpenaiResponsesWebsocketEnabled: () => isOpenaiResponsesWebsocketEnabledMock(), +})); + +function codexProvider(id = 1): Provider { + return { + id, + name: `codex-${id}`, + providerType: "codex", + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-test", + enabled: true, + weight: 1, + priority: 1, + costMultiplier: 1, + groupTag: null, + providerVendorId: null, + // minimum required shape for our code path; other fields are unused here + } as unknown as Provider; +} + +function claudeProvider(id = 2): Provider { + return { + id, + name: `claude-${id}`, + providerType: "claude", + baseUrl: "https://api.anthropic.com/v1", + apiKey: "sk-test", + enabled: true, + weight: 1, + priority: 1, + costMultiplier: 1, + groupTag: null, + providerVendorId: null, + } as unknown as Provider; +} + +describe("isWebsocketClientRequest", () => { + it("detects websocket via Headers object", () => { + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + expect(isWebsocketClientRequest(h)).toBe(true); + }); + + it("detects websocket via plain record", () => { + expect( + isWebsocketClientRequest({ [CLIENT_TRANSPORT_HEADER]: "WebSocket" } as Record) + ).toBe(true); + }); + + it("returns false when header is absent or other transport", () => { + expect(isWebsocketClientRequest({})).toBe(false); + expect( + isWebsocketClientRequest({ [CLIENT_TRANSPORT_HEADER]: "http" } as Record) + ).toBe(false); + }); +}); + +describe("evaluateResponsesWsEligibility", () => { + beforeEach(() => { + isOpenaiResponsesWebsocketEnabledMock.mockReset(); + clearResponsesWsUnsupportedCache(); + }); + + it("returns not-websocket-client when header is absent", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); + const result = await evaluateResponsesWsEligibility({ + headers: new Headers(), + provider: codexProvider(), + endpointId: null, + }); + expect(result).toEqual({ isWebsocketClient: false, eligible: false }); + }); + + it("records provider_not_codex for non-codex upstreams", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + const result = await evaluateResponsesWsEligibility({ + headers: h, + provider: claudeProvider(), + endpointId: null, + }); + expect(result.isWebsocketClient).toBe(true); + expect(result.eligible).toBe(false); + expect(result.downgradeReason).toBe("provider_not_codex"); + }); + + it("records setting_disabled when global toggle is off", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(false); + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + const result = await evaluateResponsesWsEligibility({ + headers: h, + provider: codexProvider(), + endpointId: null, + }); + expect(result.isWebsocketClient).toBe(true); + expect(result.eligible).toBe(false); + expect(result.downgradeReason).toBe("setting_disabled"); + }); + + it("records endpoint_ws_unsupported_cached when cache flag present", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); + const provider = codexProvider(99); + markResponsesWsUnsupported(provider.id, null, "ws_upgrade_rejected"); + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + const result = await evaluateResponsesWsEligibility({ + headers: h, + provider, + endpointId: null, + }); + expect(result.eligible).toBe(false); + expect(result.downgradeReason).toBe("endpoint_ws_unsupported_cached"); + }); + + it("returns eligible when all conditions are met", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + const result = await evaluateResponsesWsEligibility({ + headers: h, + provider: codexProvider(), + endpointId: null, + }); + expect(result).toMatchObject({ + isWebsocketClient: true, + eligible: true, + }); + }); +}); diff --git a/src/app/v1/_lib/responses-ws/__tests__/unsupported-cache.test.ts b/src/app/v1/_lib/responses-ws/__tests__/unsupported-cache.test.ts new file mode 100644 index 000000000..15eb905ab --- /dev/null +++ b/src/app/v1/_lib/responses-ws/__tests__/unsupported-cache.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearResponsesWsUnsupportedCache, + isResponsesWsUnsupported, + markResponsesWsUnsupported, +} from "../unsupported-cache"; + +describe("responses-ws unsupported-cache", () => { + beforeEach(() => { + clearResponsesWsUnsupportedCache(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-24T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + clearResponsesWsUnsupportedCache(); + }); + + it("returns not unsupported by default", () => { + expect(isResponsesWsUnsupported(1, null)).toEqual({ unsupported: false }); + }); + + it("records and reads back unsupported flag per (provider, endpoint)", () => { + markResponsesWsUnsupported(1, 10, "ws_upgrade_rejected"); + expect(isResponsesWsUnsupported(1, 10)).toEqual({ + unsupported: true, + reason: "ws_upgrade_rejected", + }); + // Same provider, different endpoint: not affected + expect(isResponsesWsUnsupported(1, 11)).toEqual({ unsupported: false }); + // Different provider, same endpoint number: not affected + expect(isResponsesWsUnsupported(2, 10)).toEqual({ unsupported: false }); + }); + + it("expires after TTL", () => { + markResponsesWsUnsupported(42, null, "ws_closed_before_first_event", 1000); + expect(isResponsesWsUnsupported(42, null).unsupported).toBe(true); + vi.advanceTimersByTime(2000); + expect(isResponsesWsUnsupported(42, null).unsupported).toBe(false); + }); + + it("treats null and undefined endpointId as the same 'default' bucket", () => { + markResponsesWsUnsupported(7, null, "ws_upgrade_rejected"); + expect(isResponsesWsUnsupported(7, undefined).unsupported).toBe(true); + }); +}); diff --git a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts new file mode 100644 index 000000000..d7ebcae40 --- /dev/null +++ b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts @@ -0,0 +1,197 @@ +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocketServer } from "ws"; +import type { Provider } from "@/types/provider"; +import { tryResponsesWebsocketUpstream } from "../upstream-adapter"; + +type ServerHandle = { + wss: WebSocketServer; + port: number; + close: () => Promise; +}; + +function startMockServer( + handler: (socket: import("ws").WebSocket, req: import("http").IncomingMessage) => void +): Promise { + return new Promise((resolve, reject) => { + const wss = new WebSocketServer({ port: 0 }); + wss.on("error", reject); + wss.on("listening", () => { + const address = wss.address() as AddressInfo; + wss.on("connection", handler); + resolve({ + wss, + port: address.port, + close: () => + new Promise((resolveClose) => { + wss.close(() => resolveClose()); + }), + }); + }); + }); +} + +function codexProvider(): Provider { + return { + id: 1, + name: "mock-codex", + providerType: "codex", + baseUrl: "http://mock/", + apiKey: "sk-mock", + enabled: true, + priority: 1, + weight: 1, + costMultiplier: 1, + groupTag: null, + providerVendorId: null, + } as unknown as Provider; +} + +async function collectSseBody(response: Response): Promise { + expect(response.body).toBeTruthy(); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let out = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + out += decoder.decode(value, { stream: true }); + } + out += decoder.decode(); + return out; +} + +describe("tryResponsesWebsocketUpstream", () => { + let server: ServerHandle | null = null; + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + }); + + it("yields SSE body with Responses events when upstream WS succeeds", async () => { + server = await startMockServer((socket) => { + socket.on("message", () => { + socket.send(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })); + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hi" })); + socket.send( + JSON.stringify({ + type: "response.completed", + response: { id: "resp_1", usage: { input_tokens: 1, output_tokens: 1 } }, + }) + ); + }); + }); + + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { model: "gpt-5", input: [{ role: "user", content: "hi" }] }, + }); + + expect("response" in result).toBe(true); + if (!("response" in result)) return; + expect(result.response.status).toBe(200); + expect(result.response.headers.get("content-type")).toContain("text/event-stream"); + expect(result.response.headers.get("x-cch-upstream-transport")).toBe("websocket"); + + const body = await collectSseBody(result.response); + expect(body).toContain('"type":"response.created"'); + expect(body).toContain('"type":"response.output_text.delta"'); + expect(body).toContain('"type":"response.completed"'); + }); + + it("returns failure when upstream rejects the WS upgrade", async () => { + // Create a plain http server that returns 404 on /v1/responses to simulate + // providers that don't speak WS on that path. + const http = await import("node:http"); + const httpServer = http.createServer((_req, res) => { + res.statusCode = 404; + res.end("not found"); + }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const addr = httpServer.address() as AddressInfo; + + try { + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${addr.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { model: "gpt-5", input: "hi" }, + }); + + expect("failed" in result).toBe(true); + if (!("failed" in result)) return; + expect(result.reason).toBe("ws_upgrade_rejected"); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + } + }); + + it("returns ws_closed_before_first_event when upstream accepts but closes immediately", async () => { + server = await startMockServer((socket) => { + socket.on("message", () => { + socket.close(1011, "internal"); + }); + }); + + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { model: "gpt-5", input: "hi" }, + }); + + expect("failed" in result).toBe(true); + if (!("failed" in result)) return; + expect( + result.reason === "ws_closed_before_first_event" || + result.reason === "ws_error_pre_first_event" + ).toBe(true); + }); + + it("strips stream and background transport-only fields from the forwarded frame", async () => { + let receivedFrame: unknown = null; + server = await startMockServer((socket) => { + socket.on("message", (data) => { + try { + receivedFrame = JSON.parse(data.toString("utf8")); + } catch { + receivedFrame = null; + } + socket.send( + JSON.stringify({ + type: "response.completed", + response: { id: "resp_1" }, + }) + ); + }); + }); + + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { + model: "gpt-5", + input: "hi", + stream: true, + background: false, + store: false, + }, + }); + + expect("response" in result).toBe(true); + if (!("response" in result)) return; + await collectSseBody(result.response); + + expect(receivedFrame).toBeTruthy(); + expect((receivedFrame as Record).type).toBe("response.create"); + expect((receivedFrame as Record).stream).toBeUndefined(); + expect((receivedFrame as Record).background).toBeUndefined(); + expect((receivedFrame as Record).store).toBe(false); + }); +}); diff --git a/src/app/v1/_lib/responses-ws/eligibility.ts b/src/app/v1/_lib/responses-ws/eligibility.ts new file mode 100644 index 000000000..3bc57081b --- /dev/null +++ b/src/app/v1/_lib/responses-ws/eligibility.ts @@ -0,0 +1,87 @@ +/** + * Decides whether a request forwarded by the proxy should attempt an OpenAI + * Responses WebSocket connection to the upstream. Returns a small discriminated + * result so the forwarder can both (a) decide whether to call the adapter and + * (b) record a structured downgrade reason on the decision chain when it + * declines. + * + * Eligibility requires ALL of: + * - request entered CCH via a WebSocket (`x-cch-client-transport: websocket` + * header injected by the custom Node server) + * - provider type is `codex` + * - global `enableOpenaiResponsesWebsocket` setting is on + * - the specific provider/endpoint is NOT in the short-TTL unsupported cache + */ + +import { isOpenaiResponsesWebsocketEnabled } from "@/lib/config/system-settings-cache"; +import type { Provider } from "@/types/provider"; +import { isResponsesWsUnsupported } from "./unsupported-cache"; + +export const CLIENT_TRANSPORT_HEADER = "x-cch-client-transport"; + +export type ResponsesWsDowngradeReason = + | "setting_disabled" + | "provider_not_codex" + | "endpoint_ws_unsupported_cached" + | "ws_not_yet_implemented"; + +export interface ResponsesWsEligibility { + isWebsocketClient: boolean; + eligible: boolean; + downgradeReason?: ResponsesWsDowngradeReason; + endpointId?: number | null; +} + +export function isWebsocketClientRequest(headers: Headers | Record): boolean { + const value = + headers instanceof Headers + ? headers.get(CLIENT_TRANSPORT_HEADER) + : (headers[CLIENT_TRANSPORT_HEADER] as string | undefined); + return typeof value === "string" && value.toLowerCase() === "websocket"; +} + +export async function evaluateResponsesWsEligibility(options: { + headers: Headers | Record; + provider: Provider; + endpointId?: number | null; +}): Promise { + const websocketClient = isWebsocketClientRequest(options.headers); + if (!websocketClient) { + return { isWebsocketClient: false, eligible: false }; + } + + if (options.provider.providerType !== "codex") { + return { + isWebsocketClient: true, + eligible: false, + downgradeReason: "provider_not_codex", + endpointId: options.endpointId ?? null, + }; + } + + const settingEnabled = await isOpenaiResponsesWebsocketEnabled(); + if (!settingEnabled) { + return { + isWebsocketClient: true, + eligible: false, + downgradeReason: "setting_disabled", + endpointId: options.endpointId ?? null, + }; + } + + const cache = isResponsesWsUnsupported(options.provider.id, options.endpointId); + if (cache.unsupported) { + return { + isWebsocketClient: true, + eligible: false, + downgradeReason: "endpoint_ws_unsupported_cached", + endpointId: options.endpointId ?? null, + }; + } + + return { + isWebsocketClient: true, + eligible: true, + endpointId: options.endpointId ?? null, + }; +} diff --git a/src/app/v1/_lib/responses-ws/unsupported-cache.ts b/src/app/v1/_lib/responses-ws/unsupported-cache.ts new file mode 100644 index 000000000..4107e0158 --- /dev/null +++ b/src/app/v1/_lib/responses-ws/unsupported-cache.ts @@ -0,0 +1,53 @@ +/** + * Short-TTL in-memory cache for provider endpoints known to NOT support the + * OpenAI Responses WebSocket transport. + * + * Populated when an upstream WebSocket handshake is rejected or closes before + * emitting any response event. Used by the forwarder to skip the WS attempt + * and go straight to HTTP for the duration of the TTL. Not persisted to Redis + * or disk; a process restart clears the cache (which is fine — the next + * request simply re-probes once and re-caches if still unsupported). + */ + +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +type Entry = { + expiresAt: number; + reason: string; +}; + +const cache = new Map(); + +function buildKey(providerId: number, endpointId: number | null | undefined): string { + return `${providerId}:${endpointId ?? "default"}`; +} + +export function markResponsesWsUnsupported( + providerId: number, + endpointId: number | null | undefined, + reason: string, + ttlMs: number = DEFAULT_TTL_MS +): void { + cache.set(buildKey(providerId, endpointId), { + expiresAt: Date.now() + Math.max(1000, ttlMs), + reason, + }); +} + +export function isResponsesWsUnsupported( + providerId: number, + endpointId: number | null | undefined +): { unsupported: boolean; reason?: string } { + const key = buildKey(providerId, endpointId); + const entry = cache.get(key); + if (!entry) return { unsupported: false }; + if (Date.now() >= entry.expiresAt) { + cache.delete(key); + return { unsupported: false }; + } + return { unsupported: true, reason: entry.reason }; +} + +export function clearResponsesWsUnsupportedCache(): void { + cache.clear(); +} diff --git a/src/app/v1/_lib/responses-ws/upstream-adapter.ts b/src/app/v1/_lib/responses-ws/upstream-adapter.ts new file mode 100644 index 000000000..e9887bf13 --- /dev/null +++ b/src/app/v1/_lib/responses-ws/upstream-adapter.ts @@ -0,0 +1,357 @@ +/** + * OpenAI Responses WebSocket upstream adapter (Codex providers only). + * + * Attempts a WebSocket connection to the upstream's `/v1/responses` endpoint. + * On success, events received from the upstream WS are re-emitted as SSE + * frames so that the forwarder's downstream pipeline (fake-200 detection, + * prompt_cache_key extraction, usage aggregation, finalization) treats the + * response exactly like an HTTP Responses SSE stream. + * + * On handshake rejection, close-before-first-event, or other fallback-safe + * errors, returns null so the caller can fall back to the HTTP path. No + * circuit-breaker accounting happens here — the fallback is purely informational. + * + * Scope: this adapter only handles the pre-flight connection attempt. It does + * NOT re-use connections across requests (first pass); each call opens and + * closes its own WebSocket. A future revision can add per-socket pooling and + * previous_response_id delta frames. + */ + +import type WebSocketType from "ws"; +import { logger } from "@/lib/logger"; +import type { Provider } from "@/types/provider"; + +export interface UpstreamWsOutcome { + response: Response; + connected: boolean; +} + +export type UpstreamWsFallbackReason = + | "ws_module_unavailable" + | "ws_upgrade_rejected" + | "ws_closed_before_first_event" + | "ws_error_pre_first_event"; + +export interface UpstreamWsFailure { + failed: true; + reason: UpstreamWsFallbackReason; + message?: string; +} + +export type UpstreamWsResult = UpstreamWsOutcome | UpstreamWsFailure; + +const TERMINAL_EVENT_TYPES = new Set([ + "response.completed", + "response.failed", + "response.incomplete", + "error", +]); + +const HANDSHAKE_TIMEOUT_MS = 10_000; + +function toWsUrl(httpUrl: string): string { + const url = new URL(httpUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +} + +function stripTransportOnlyFields>(body: T): T { + const copy: Record = { ...body }; + delete copy.stream; + delete copy.background; + return copy as T; +} + +async function loadWsModule(): Promise { + try { + const mod = await import("ws"); + return (mod.default ?? mod) as unknown as typeof WebSocketType; + } catch (err) { + logger.warn("[ResponsesWsAdapter] ws module unavailable, falling back to HTTP", { + error: String(err), + }); + return null; + } +} + +export async function tryResponsesWebsocketUpstream(options: { + provider: Provider; + upstreamUrl: string; + upstreamHeaders: Headers | Record; + body: Record; + abortSignal?: AbortSignal; +}): Promise { + const WsCtor = (await loadWsModule()) as + | (typeof WebSocketType & { new (url: string, opts?: unknown): WebSocketType }) + | null; + if (!WsCtor) { + return { failed: true, reason: "ws_module_unavailable" }; + } + + const wssUrl = toWsUrl(options.upstreamUrl); + const headers: Record = {}; + if (options.upstreamHeaders instanceof Headers) { + options.upstreamHeaders.forEach((value, key) => { + const lower = key.toLowerCase(); + // ws package handles Connection/Upgrade/Sec-WebSocket-* itself. + if ( + lower === "connection" || + lower === "upgrade" || + lower === "sec-websocket-key" || + lower === "sec-websocket-version" || + lower === "sec-websocket-extensions" || + lower === "sec-websocket-protocol" || + lower === "host" || + lower === "content-length" || + lower === "transfer-encoding" || + lower === "accept" || + lower === "content-type" + ) { + return; + } + headers[key] = value; + }); + } else { + for (const [k, v] of Object.entries(options.upstreamHeaders)) { + headers[k] = v; + } + } + + const frame = { + type: "response.create", + ...stripTransportOnlyFields(options.body), + }; + + let ws: WebSocketType; + try { + ws = new (WsCtor as unknown as new (url: string, opts?: unknown) => WebSocketType)(wssUrl, { + headers, + handshakeTimeout: HANDSHAKE_TIMEOUT_MS, + }); + } catch (err) { + return { + failed: true, + reason: "ws_upgrade_rejected", + message: String(err && (err as Error).message ? (err as Error).message : err), + }; + } + + let firstEventSeen = false; + let openResolved = false; + let openPromiseResolve: ( + v: { ok: true } | { ok: false; reason: UpstreamWsFallbackReason; message?: string } + ) => void; + const openPromise = new Promise< + { ok: true } | { ok: false; reason: UpstreamWsFallbackReason; message?: string } + >((resolve) => { + openPromiseResolve = resolve; + }); + + const finishOpen = ( + result: { ok: true } | { ok: false; reason: UpstreamWsFallbackReason; message?: string } + ) => { + if (openResolved) return; + openResolved = true; + openPromiseResolve(result); + }; + + ws.on("open", () => { + try { + ws.send(JSON.stringify(frame)); + } catch (err) { + finishOpen({ + ok: false, + reason: "ws_error_pre_first_event", + message: String(err && (err as Error).message ? (err as Error).message : err), + }); + try { + ws.close(1011); + } catch { + // ignore + } + } + }); + + ws.on( + "unexpected-response", + (_req: unknown, res: { statusCode?: number; statusMessage?: string }) => { + finishOpen({ + ok: false, + reason: "ws_upgrade_rejected", + message: `HTTP ${res.statusCode ?? "?"} ${res.statusMessage ?? ""}`.trim(), + }); + try { + ws.close(1011); + } catch { + // ignore + } + } + ); + + const messageQueue: string[] = []; + let queueResolver: ((value: string | null) => void) | null = null; + let closed = false; + let closeReason: UpstreamWsFallbackReason | null = null; + + ws.on("message", (data: Buffer | string) => { + const text = typeof data === "string" ? data : data.toString("utf8"); + if (!firstEventSeen) { + firstEventSeen = true; + finishOpen({ ok: true }); + } + if (queueResolver) { + const resolve = queueResolver; + queueResolver = null; + resolve(text); + } else { + messageQueue.push(text); + } + }); + + ws.on("error", (err: Error) => { + logger.warn("[ResponsesWsAdapter] upstream ws error", { + error: String(err?.message ? err.message : err), + firstEventSeen, + }); + if (!firstEventSeen) { + finishOpen({ + ok: false, + reason: "ws_error_pre_first_event", + message: String(err?.message ? err.message : err), + }); + } + closed = true; + closeReason = firstEventSeen ? null : "ws_error_pre_first_event"; + if (queueResolver) { + const resolve = queueResolver; + queueResolver = null; + resolve(null); + } + }); + + ws.on("close", () => { + closed = true; + if (!firstEventSeen) { + finishOpen({ + ok: false, + reason: "ws_closed_before_first_event", + }); + } + if (queueResolver) { + const resolve = queueResolver; + queueResolver = null; + resolve(null); + } + }); + + if (options.abortSignal) { + options.abortSignal.addEventListener( + "abort", + () => { + try { + ws.close(1000); + } catch { + // ignore + } + }, + { once: true } + ); + } + + const openResult = await openPromise; + if (!openResult.ok) { + try { + ws.terminate?.(); + } catch { + // ignore + } + return { failed: true, reason: openResult.reason, message: openResult.message }; + } + + // Upstream WS is open and at least one event was received. Build an SSE + // ReadableStream that replays queued messages and streams future ones until + // a terminal event arrives or the connection closes. + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const writeLine = (obj: string) => { + controller.enqueue(encoder.encode(`data: ${obj}\n\n`)); + }; + + const processText = (text: string): boolean => { + writeLine(text); + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed.type === "string" && TERMINAL_EVENT_TYPES.has(parsed.type)) { + return true; + } + } catch { + // Non-JSON upstream text: still forwarded, not terminal. + } + return false; + }; + + // Drain queued first-event(s) + while (messageQueue.length > 0) { + const msg = messageQueue.shift(); + if (msg === undefined) break; + if (processText(msg)) { + controller.close(); + try { + ws.close(1000); + } catch { + // ignore + } + return; + } + } + + while (!closed) { + const next = await new Promise((resolve) => { + if (messageQueue.length > 0) { + resolve(messageQueue.shift() ?? null); + return; + } + queueResolver = resolve; + }); + if (next === null) break; + if (processText(next)) { + controller.close(); + try { + ws.close(1000); + } catch { + // ignore + } + return; + } + } + + if (closeReason === "ws_error_pre_first_event") { + // Shouldn't happen: we only reach here if firstEventSeen=true. + controller.error(new Error("upstream_ws_mid_stream_error")); + return; + } + + controller.close(); + }, + cancel() { + try { + ws.close(1000); + } catch { + // ignore + } + }, + }); + + return { + response: new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + "x-cch-upstream-transport": "websocket", + }, + }), + connected: true, + }; +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 04623fecd..2e9d28527 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -757,6 +757,13 @@ export const systemSettings = pgTable('system_settings', { // 启用 HTTP/2 连接供应商(默认关闭,启用后自动回退到 HTTP/1.1 失败时) enableHttp2: boolean('enable_http2').notNull().default(false), + // 启用 OpenAI Responses WebSocket 支持(默认开启,仅对 Codex 类型供应商生效) + // 开启后:当客户端以 WebSocket 建连至 /v1/responses 时,CCH 会尝试与上游建立 WS 连接; + // 若上游不支持或握手失败,优雅降级为普通 HTTP Responses 请求,客户端 WebSocket 保持打开。 + enableOpenaiResponsesWebsocket: boolean('enable_openai_responses_websocket') + .notNull() + .default(true), + // 高并发模式(默认关闭) // 开启后:关闭部分 Redis 调试快照与实时观测写入,降低高并发下的 CPU 与 IO 开销 enableHighConcurrencyMode: boolean('enable_high_concurrency_mode').notNull().default(false), diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 51b5e200e..bc57c19f0 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -35,6 +35,7 @@ export function getCachedSystemSettingsOnlyCache(): SystemSettings | null { const DEFAULT_SETTINGS: Pick< SystemSettings, | "enableHttp2" + | "enableOpenaiResponsesWebsocket" | "enableHighConcurrencyMode" | "interceptAnthropicWarmupRequests" | "codexPriorityBillingSource" @@ -52,6 +53,7 @@ const DEFAULT_SETTINGS: Pick< | "publicStatusAggregationIntervalMinutes" > = { enableHttp2: false, + enableOpenaiResponsesWebsocket: true, enableHighConcurrencyMode: false, interceptAnthropicWarmupRequests: false, codexPriorityBillingSource: "requested", @@ -135,6 +137,7 @@ export async function getCachedSystemSettings(): Promise { cleanupBatchSize: 10000, enableClientVersionCheck: false, enableHttp2: DEFAULT_SETTINGS.enableHttp2, + enableOpenaiResponsesWebsocket: DEFAULT_SETTINGS.enableOpenaiResponsesWebsocket, enableHighConcurrencyMode: DEFAULT_SETTINGS.enableHighConcurrencyMode, interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, @@ -174,6 +177,17 @@ export async function isHttp2Enabled(): Promise { return settings.enableHttp2; } +/** + * Get only the OpenAI Responses WebSocket enabled setting. + * Only effective for Codex-type providers. + * + * @returns Whether OpenAI Responses WebSocket support is enabled globally. + */ +export async function isOpenaiResponsesWebsocketEnabled(): Promise { + const settings = await getCachedSystemSettings(); + return settings.enableOpenaiResponsesWebsocket; +} + /** * Invalidate the settings cache * diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index a20b3094e..2f249d424 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -960,6 +960,8 @@ export const UpdateSystemSettingsSchema = z.object({ passThroughUpstreamErrorMessage: z.boolean().optional(), // 启用 HTTP/2 连接供应商(可选) enableHttp2: z.boolean().optional(), + // 启用 OpenAI Responses WebSocket 支持(可选,仅 Codex 类型供应商生效) + enableOpenaiResponsesWebsocket: z.boolean().optional(), // 高并发模式(可选) enableHighConcurrencyMode: z.boolean().optional(), // 可选拦截 Anthropic Warmup 请求(可选) diff --git a/src/repository/_shared/transformers.test.ts b/src/repository/_shared/transformers.test.ts index e83f958d7..69ac7e8cb 100644 --- a/src/repository/_shared/transformers.test.ts +++ b/src/repository/_shared/transformers.test.ts @@ -287,6 +287,7 @@ describe("src/repository/_shared/transformers.ts", () => { expect(result.verboseProviderError).toBe(false); expect(result.passThroughUpstreamErrorMessage).toBe(true); expect(result.enableHttp2).toBe(false); + expect(result.enableOpenaiResponsesWebsocket).toBe(true); expect(result.interceptAnthropicWarmupRequests).toBe(false); expect(result.createdAt).toEqual(now); expect(result.updatedAt).toEqual(now); diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 6849bd77f..f9d68418a 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -219,6 +219,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { verboseProviderError: dbSettings?.verboseProviderError ?? false, passThroughUpstreamErrorMessage: dbSettings?.passThroughUpstreamErrorMessage ?? true, enableHttp2: dbSettings?.enableHttp2 ?? false, + enableOpenaiResponsesWebsocket: dbSettings?.enableOpenaiResponsesWebsocket ?? true, enableHighConcurrencyMode: dbSettings?.enableHighConcurrencyMode ?? false, interceptAnthropicWarmupRequests: dbSettings?.interceptAnthropicWarmupRequests ?? false, enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true, diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 91006166a..b5d32635a 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -152,6 +152,7 @@ function createFallbackSettings(): SystemSettings { verboseProviderError: false, passThroughUpstreamErrorMessage: true, enableHttp2: false, + enableOpenaiResponsesWebsocket: true, enableHighConcurrencyMode: false, interceptAnthropicWarmupRequests: false, enableThinkingSignatureRectifier: true, @@ -268,10 +269,14 @@ export async function getSystemSettings(): Promise { createdAt: systemSettings.createdAt, updatedAt: systemSettings.updatedAt, }; - const fullSelection = { + const selectionWithoutOpenaiResponsesWebsocket = { passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, ...selectionWithoutPassThrough, }; + const fullSelection = { + ...selectionWithoutOpenaiResponsesWebsocket, + enableOpenaiResponsesWebsocket: systemSettings.enableOpenaiResponsesWebsocket, + }; try { const [row] = await db @@ -287,12 +292,32 @@ export async function getSystemSettings(): Promise { error, }); + // 第零层降级:仅移除最新增加的 enableOpenaiResponsesWebsocket 列, + // 其它已迁移的现代字段保留。 + try { + const [row] = await db + .select(selectionWithoutOpenaiResponsesWebsocket) + .from(systemSettings) + .orderBy(asc(systemSettings.id)) + .limit(1); + return row ?? null; + } catch (openaiResponsesWebsocketFallbackError) { + if (!isUndefinedColumnError(openaiResponsesWebsocketFallbackError)) { + throw openaiResponsesWebsocketFallbackError; + } + + logger.warn( + "system_settings 表除 enableOpenaiResponsesWebsocket 外仍有列缺失,继续回退。", + { error: openaiResponsesWebsocketFallbackError } + ); + } + // 第一层降级:仅移除本次新增的 allowNonConversationEndpointProviderFallback 列, // 其它已迁移的现代字段保留,避免只缺该列时其它设置被连带默认化。 const { allowNonConversationEndpointProviderFallback: _omitNonConversationFallback, ...selectionWithoutNonConversationFallback - } = fullSelection; + } = selectionWithoutOpenaiResponsesWebsocket; try { const [row] = await db @@ -531,10 +556,14 @@ export async function updateSystemSettings( createdAt: systemSettings.createdAt, updatedAt: systemSettings.updatedAt, }; - const fullReturning = { + const returningWithoutOpenaiResponsesWebsocket = { passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, ...returningWithoutPassThrough, }; + const fullReturning = { + ...returningWithoutOpenaiResponsesWebsocket, + enableOpenaiResponsesWebsocket: systemSettings.enableOpenaiResponsesWebsocket, + }; try { const current = await getSystemSettings(); @@ -604,6 +633,11 @@ export async function updateSystemSettings( updates.enableHttp2 = payload.enableHttp2; } + // OpenAI Responses WebSocket 支持开关(如果提供,仅 Codex 类型供应商生效) + if (payload.enableOpenaiResponsesWebsocket !== undefined) { + updates.enableOpenaiResponsesWebsocket = payload.enableOpenaiResponsesWebsocket; + } + // 高并发模式开关(如果提供) if (payload.enableHighConcurrencyMode !== undefined) { updates.enableHighConcurrencyMode = payload.enableHighConcurrencyMode; @@ -714,16 +748,44 @@ export async function updateSystemSettings( error, }); + // 第零层降级:仅移除最新增加的 enableOpenaiResponsesWebsocket 列, + // 其它字段继续原值更新/返回。 + const { + enableOpenaiResponsesWebsocket: _omitUpdateOpenaiResponsesWebsocket, + ...updatesWithoutOpenaiResponsesWebsocket + } = updates; + + try { + [updated] = await executor + .update(systemSettings) + .set(updatesWithoutOpenaiResponsesWebsocket) + .where(eq(systemSettings.id, current.id)) + .returning(returningWithoutOpenaiResponsesWebsocket); + } catch (openaiResponsesWebsocketFallbackError) { + if (!isUndefinedColumnError(openaiResponsesWebsocketFallbackError)) { + throw openaiResponsesWebsocketFallbackError; + } + + logger.warn( + "system_settings 表除 enableOpenaiResponsesWebsocket 外仍有列缺失,继续降级更新。", + { error: openaiResponsesWebsocketFallbackError } + ); + } + + if (updated) { + return toSystemSettings(updated); + } + // 第一层降级:仅移除本次新增的 allowNonConversationEndpointProviderFallback 列, // 其它字段继续原值更新 / 返回,避免只缺该列时连带丢失 codex/highConcurrency 等更新。 const { allowNonConversationEndpointProviderFallback: _omitUpdate, ...updatesWithoutNonConversationFallback - } = updates; + } = updatesWithoutOpenaiResponsesWebsocket; const { allowNonConversationEndpointProviderFallback: _omitReturning, ...returningWithoutNonConversationFallback - } = fullReturning; + } = returningWithoutOpenaiResponsesWebsocket; try { [updated] = await executor @@ -744,6 +806,7 @@ export async function updateSystemSettings( try { const withoutPassThroughUpdates = { ...updates }; delete withoutPassThroughUpdates.passThroughUpstreamErrorMessage; + delete withoutPassThroughUpdates.enableOpenaiResponsesWebsocket; [updated] = await executor .update(systemSettings) .set(withoutPassThroughUpdates) @@ -761,6 +824,7 @@ export async function updateSystemSettings( delete downgradedUpdates.publicStatusAggregationIntervalMinutes; delete downgradedUpdates.ipExtractionConfig; delete downgradedUpdates.ipGeoLookupEnabled; + delete downgradedUpdates.enableOpenaiResponsesWebsocket; const legacyUpdates = { ...downgradedUpdates }; delete legacyUpdates.codexPriorityBillingSource; diff --git a/src/types/message.ts b/src/types/message.ts index 993808933..20eddfeb7 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -36,6 +36,8 @@ export interface ProviderChainItem { | "retry_with_cached_instructions" // Codex instructions 智能重试(缓存) | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) + | "responses_ws_attempted" // 已尝试与上游建立 OpenAI Responses WebSocket 连接(信息性,无论最终成功或降级) + | "responses_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) @@ -220,6 +222,26 @@ export interface ProviderChainItem { // --- 重试特有 --- excludedProviderIds?: number[]; // 已排除的供应商 ID 列表 retryReason?: string; // 重试原因 + + // --- OpenAI Responses WebSocket 特有(clientTransport==="websocket" 时相关) --- + // 客户端进入本次请求使用的传输层 + clientTransport?: "http" | "websocket"; + // 是否尝试了上游 WebSocket 建连(仅当 clientTransport==="websocket" 且供应商为 codex 且全局开关开启时才会为 true) + upstreamWsAttempted?: boolean; + // 上游 WebSocket 是否至少建连并收到一个事件 + upstreamWsConnected?: boolean; + // 是否最终降级到 HTTP Responses(不计入熔断器) + downgradedToHttp?: boolean; + // 降级原因(仅当 downgradedToHttp===true 时填充) + downgradeReason?: + | "setting_disabled" + | "provider_not_codex" + | "endpoint_ws_unsupported_cached" + | "ws_upgrade_rejected" + | "ws_closed_before_first_event" + | "ws_module_unavailable" + | "ws_not_yet_implemented" + | "ws_error_pre_first_event"; }; } diff --git a/src/types/system-config.ts b/src/types/system-config.ts index 704e1703d..461373752 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -50,6 +50,11 @@ export interface SystemSettings { // 启用 HTTP/2 连接供应商 enableHttp2: boolean; + // 启用 OpenAI Responses WebSocket 支持(仅 Codex 类型供应商生效) + // 目标:让客户端以 WebSocket 连接 /v1/responses 时,CCH 与上游也以 WS 建连; + // 上游不支持时优雅降级为 HTTP,客户端 WebSocket 保持打开。 + enableOpenaiResponsesWebsocket: boolean; + // 高并发模式(默认关闭) // 目标:关闭部分 Redis 调试快照与实时观测写入,降低高并发下的 CPU 与 IO 开销 enableHighConcurrencyMode: boolean; @@ -146,6 +151,9 @@ export interface UpdateSystemSettingsInput { // 启用 HTTP/2 连接供应商(可选) enableHttp2?: boolean; + // 启用 OpenAI Responses WebSocket 支持(可选,仅 Codex 类型供应商生效) + enableOpenaiResponsesWebsocket?: boolean; + // 高并发模式(可选) enableHighConcurrencyMode?: boolean; From 02f8ba79201d860d9df6e62c19faba9f48cc50c5 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 25 Apr 2026 11:39:33 +0800 Subject: [PATCH 02/47] fix(proxy): address PR review on responses websocket support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.js: - Cap per-connection pending-frame queue (count + bytes) to bound DoS surface; close with 1008 when exceeded. - Log `error` events instead of silently swallowing them, and add `.catch` branches around `drain()` so async failures cannot become unhandled rejections. - Track the in-flight internal `http.ClientRequest` and `req.destroy()` it on client WebSocket close/error so orphaned upstream streams stop leaking TCP/provider concurrency slots. - Resolve the internal tunnel host from the configured `HOSTNAME` (with loopback fallback for `0.0.0.0`/`::`) so containerized deployments that bind to a non-loopback interface don't ECONNREFUSED their own tunnel. - Sanitize `req.url` before logging on `ws_client_connected` (only `model` is allow-listed; everything else is masked) so api_key/token query values do not land in structured logs. - Parse SSE event boundaries with /\r?\n\r?\n/ and trim `[DONE]` so CRLF-terminated streams (common in real upstreams) are decoded correctly. upstream-adapter.ts: - Share the hop-by-hop / shape-header filter between the `Headers` and plain `Record` branches, so a future caller passing a plain object can't leak `connection`/`host`/`content-length` etc. into the WS upgrade. - Add a 20s first-event timeout to bound the wait after a successful upgrade but before any frame arrives — silent upstreams no longer hang the request. - When the upstream WS closes mid-stream without emitting a terminal event, enqueue a synthetic `{type:"error", error:{code:"upstream_ws_mid_stream_error"}}` frame so the downstream pipeline (fake-200 detection, finalization) treats the truncated stream as an error rather than a clean success. eligibility.ts: - Header lookup on a plain `Record` is now case-insensitive, matching HTTP header semantics. forwarder.ts: - Pass the real `endpointAudit.endpointId` into both `evaluateResponsesWsEligibility` and `markResponsesWsUnsupported`, restoring per-endpoint isolation in the unsupported cache. - Decode the *final* outgoing `requestBody` (Buffer/string) into JSON for the WS frame so private-parameter filtering and `requestFilterEngine.applyFinal` rewrites apply identically on the WS and HTTP paths. - Drop a stray decorative emoji from a code comment (CLAUDE.md rule 1). settings UI: - Add an aria-label on the new toggle so screen readers can identify it. Co-Authored-By: Claude Opus 4.7 (1M context) --- server.js | 215 +++++++++++++++--- .../_components/system-settings-form.tsx | 1 + src/app/v1/_lib/proxy/forwarder.ts | 57 ++++- .../__tests__/eligibility.test.ts | 9 + .../__tests__/server-helpers.test.ts | 34 +++ .../__tests__/upstream-adapter.test.ts | 74 ++++++ src/app/v1/_lib/responses-ws/eligibility.ts | 17 +- .../v1/_lib/responses-ws/upstream-adapter.ts | 134 ++++++++--- .../leaderboard-view-filter-gating.test.tsx | 2 +- .../leaderboard-view-tab-grouping.test.tsx | 2 +- 10 files changed, 466 insertions(+), 79 deletions(-) create mode 100644 src/app/v1/_lib/responses-ws/__tests__/server-helpers.test.ts diff --git a/server.js b/server.js index fbc4a5ef1..1a94c0e61 100644 --- a/server.js +++ b/server.js @@ -28,10 +28,22 @@ const dev = process.env.NODE_ENV !== "production"; const hostname = process.env.HOSTNAME || "0.0.0.0"; const port = parseInt(process.env.PORT || (dev ? "13500" : "3000"), 10); +// Loopback target for the in-process WS->HTTP tunnel. When the public bind +// hostname is a wildcard (0.0.0.0 / ::), tunnel via 127.0.0.1; otherwise use +// the configured hostname so we still hit the local listener even when bound +// to a specific interface. +const INTERNAL_TUNNEL_HOST = + hostname === "0.0.0.0" || hostname === "::" || hostname === "*" ? "127.0.0.1" : hostname; + const WS_PATH = "/v1/responses"; const CLIENT_TRANSPORT_HEADER = "x-cch-client-transport"; const WS_FORWARD_FLAG_HEADER = "x-cch-responses-ws-forward"; +// Per-WebSocket-connection guardrails: cap the queue depth and total queued +// bytes to make a misbehaving / malicious client a bounded-memory event. +const MAX_PENDING_FRAMES = 64; +const MAX_PENDING_BYTES = 4 * 1024 * 1024; // 4 MiB across all queued frames + const TERMINAL_EVENT_TYPES = new Set([ "response.completed", "response.failed", @@ -39,6 +51,10 @@ const TERMINAL_EVENT_TYPES = new Set([ "error", ]); +// Query-string keys we explicitly never want to log on the connection event. +// Anything outside this list is masked to "***". +const ALLOWED_LOGGED_QUERY_KEYS = new Set(["model"]); + function log(level, msg, extra) { const line = { ts: new Date().toISOString(), level, msg, ...(extra || {}) }; try { @@ -67,18 +83,56 @@ function emitErrorEvent(ws, code, message) { }); } +function sanitizedRequestPath(rawUrl) { + if (typeof rawUrl !== "string" || rawUrl.length === 0) { + return "/"; + } + try { + const parsed = new URL(rawUrl, "http://localhost"); + const masked = new URLSearchParams(); + parsed.searchParams.forEach((value, key) => { + masked.append(key, ALLOWED_LOGGED_QUERY_KEYS.has(key.toLowerCase()) ? value : "***"); + }); + const qs = masked.toString(); + return qs.length > 0 ? `${parsed.pathname}?${qs}` : parsed.pathname; + } catch { + return "/"; + } +} + async function handleWebSocketConnection(ws, req) { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); const queryModel = url.searchParams.get("model"); let inFlight = false; const pending = []; + let pendingBytes = 0; let closed = false; + // Track the in-flight internal HTTP ClientRequest so we can abort it when + // the client WebSocket disconnects mid-stream — otherwise the SSE consumer + // (and provider concurrency / breaker counters) keep running for minutes. + let currentInternalReq = null; const finalize = () => { closed = true; + if (currentInternalReq) { + try { + currentInternalReq.destroy(); + } catch { + // ignore + } + currentInternalReq = null; + } + pending.length = 0; + pendingBytes = 0; }; + ws.on("close", finalize); - ws.on("error", finalize); + ws.on("error", (err) => { + log("warn", "ws_client_error", { + error: String(err && err.message ? err.message : err), + }); + finalize(); + }); const processFrame = async (raw) => { if (closed) return; @@ -97,7 +151,11 @@ async function handleWebSocketConnection(ws, req) { try { frame = JSON.parse(raw); } catch (err) { - emitErrorEvent(ws, "invalid_json", `Invalid JSON frame: ${err && err.message ? err.message : "parse error"}`); + emitErrorEvent( + ws, + "invalid_json", + `Invalid JSON frame: ${err && err.message ? err.message : "parse error"}` + ); return; } @@ -107,11 +165,15 @@ async function handleWebSocketConnection(ws, req) { } if (frame.type !== "response.create") { - emitErrorEvent(ws, "unsupported_event_type", `Only type=response.create is supported; received: ${frame.type ?? "(missing)"}`); + emitErrorEvent( + ws, + "unsupported_event_type", + `Only type=response.create is supported; received: ${frame.type ?? "(missing)"}` + ); return; } - const { type, ...rawBody } = frame; + const { type: _type, ...rawBody } = frame; const body = { ...rawBody }; // body.model wins over query; only fill from query when body lacks a model // (LiteLLM/other compat). Drop transport-only fields. @@ -119,20 +181,37 @@ async function handleWebSocketConnection(ws, req) { body.model = queryModel; } - await forwardToInternalHttp(ws, req, body); + await forwardToInternalHttp(ws, req, body, (clientReq) => { + currentInternalReq = clientReq; + }); + if (!closed) { + currentInternalReq = null; + } }; const drain = async () => { if (inFlight) return; const next = pending.shift(); - if (!next) return; + if (next === undefined) return; + pendingBytes -= Buffer.byteLength(next, "utf8"); + if (pendingBytes < 0) pendingBytes = 0; inFlight = true; try { await processFrame(next); } finally { inFlight = false; if (pending.length > 0 && !closed) { - void drain(); + void drain().catch((err) => { + log("error", "ws_drain_failed", { + error: String(err && err.message ? err.message : err), + }); + emitErrorEvent(ws, "internal_error", "Failed to process queued request"); + try { + ws.close(1011, "internal_error"); + } catch { + // ignore + } + }); } } }; @@ -148,12 +227,39 @@ async function handleWebSocketConnection(ws, req) { } return; } - pending.push(data.toString("utf8")); - void drain(); + const text = data.toString("utf8"); + const size = Buffer.byteLength(text, "utf8"); + if (pending.length >= MAX_PENDING_FRAMES || pendingBytes + size > MAX_PENDING_BYTES) { + log("warn", "ws_pending_overflow", { + pendingFrames: pending.length, + pendingBytes, + attemptedFrameSize: size, + }); + emitErrorEvent(ws, "too_many_requests", "Pending frame limit exceeded"); + try { + ws.close(1008, "too_many_requests"); + } catch { + // ignore + } + return; + } + pending.push(text); + pendingBytes += size; + void drain().catch((err) => { + log("error", "ws_drain_failed", { + error: String(err && err.message ? err.message : err), + }); + emitErrorEvent(ws, "internal_error", "Failed to process request"); + try { + ws.close(1011, "internal_error"); + } catch { + // ignore + } + }); }); } -async function forwardToInternalHttp(ws, originalReq, body) { +async function forwardToInternalHttp(ws, originalReq, body, registerInternalReq) { const internalHeaders = {}; for (const [k, v] of Object.entries(originalReq.headers)) { // Skip hop-by-hop / WS-specific headers; keep app-level auth/session etc. @@ -195,7 +301,7 @@ async function forwardToInternalHttp(ws, originalReq, body) { const req = http.request( { method: "POST", - hostname: "127.0.0.1", + hostname: INTERNAL_TUNNEL_HOST, port, path: "/v1/responses", headers: internalHeaders, @@ -220,9 +326,10 @@ async function forwardToInternalHttp(ws, originalReq, body) { if (res.statusCode && res.statusCode >= 400) { safeSend(ws, { type: "error", - error: typeof parsed === "object" && parsed && parsed.error - ? parsed.error - : { code: `http_${res.statusCode}`, message: text.slice(0, 512) }, + error: + typeof parsed === "object" && parsed && parsed.error + ? parsed.error + : { code: `http_${res.statusCode}`, message: text.slice(0, 512) }, }); } else { safeSend(ws, { @@ -233,31 +340,40 @@ async function forwardToInternalHttp(ws, originalReq, body) { resolve(); }); res.on("error", (err) => { - emitErrorEvent(ws, "internal_response_error", String(err && err.message ? err.message : err)); + emitErrorEvent( + ws, + "internal_response_error", + String(err && err.message ? err.message : err) + ); resolve(); }); return; } // SSE path: decode `data:` events and emit each as a WS JSON frame. + // Accept both LF (`\n\n`) and CRLF (`\r\n\r\n`) event separators since + // upstreams in the wild emit either form. let buffer = ""; let sawTerminal = false; + const EVENT_DELIMITER = /\r?\n\r?\n/; const flushEvents = () => { - let idx; - while ((idx = buffer.indexOf("\n\n")) !== -1) { - const chunk = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); + const parts = buffer.split(EVENT_DELIMITER); + // Last part may be a partial event still arriving — keep it buffered. + buffer = parts.pop() ?? ""; + for (const chunk of parts) { const lines = chunk.split(/\r?\n/); const dataLines = []; for (const line of lines) { if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()); + // Trim CR / leading whitespace so trailing \r from CRLF lines + // doesn't end up inside the payload. + dataLines.push(line.slice(5).trim()); } } if (dataLines.length === 0) continue; const dataText = dataLines.join("\n"); - if (dataText === "[DONE]") { + if (dataText.trim() === "[DONE]") { if (!sawTerminal) { // Some upstreams close SSE with [DONE] without a preceding // response.completed. Synthesize one so the client sees a @@ -303,17 +419,34 @@ async function forwardToInternalHttp(ws, originalReq, body) { resolve(); }); res.on("error", (err) => { - emitErrorEvent(ws, "internal_response_error", String(err && err.message ? err.message : err)); + emitErrorEvent( + ws, + "internal_response_error", + String(err && err.message ? err.message : err) + ); resolve(); }); } ); req.on("error", (err) => { - emitErrorEvent(ws, "internal_request_error", String(err && err.message ? err.message : err)); + // ECONNRESET when we destroy() the request on client disconnect is + // expected; downgrade to debug to avoid noisy logs in normal traffic. + const errCode = err && (err.code || err.name); + const isAbort = errCode === "ECONNRESET" || errCode === "ERR_STREAM_PREMATURE_CLOSE"; + if (!isAbort) { + emitErrorEvent( + ws, + "internal_request_error", + String(err && err.message ? err.message : err) + ); + } resolve(); }); + if (typeof registerInternalReq === "function") { + registerInternalReq(req); + } req.write(payload); req.end(); }); @@ -334,7 +467,9 @@ async function main() { // eslint-disable-next-line global-require nextModule = require("next"); } catch (err) { - log("error", "next_import_failed", { error: String(err && err.message ? err.message : err) }); + log("error", "next_import_failed", { + error: String(err && err.message ? err.message : err), + }); process.exit(1); return; } @@ -360,7 +495,9 @@ async function main() { const parsedUrl = parse(req.url, true); await handler(req, res, parsedUrl); } catch (err) { - log("error", "http_handler_error", { error: String(err && err.message ? err.message : err) }); + log("error", "http_handler_error", { + error: String(err && err.message ? err.message : err), + }); if (!res.headersSent) { res.statusCode = 500; res.end("Internal Server Error"); @@ -377,9 +514,11 @@ async function main() { return; } wss.handleUpgrade(req, socket, head, (ws) => { - log("info", "ws_client_connected", { path: req.url }); + log("info", "ws_client_connected", { path: sanitizedRequestPath(req.url) }); handleWebSocketConnection(ws, req).catch((err) => { - log("error", "ws_handler_error", { error: String(err && err.message ? err.message : err) }); + log("error", "ws_handler_error", { + error: String(err && err.message ? err.message : err), + }); try { ws.close(1011, "internal_error"); } catch { @@ -395,11 +534,23 @@ async function main() { } server.listen(port, hostname, () => { - log("info", "server_listening", { hostname, port, wsEnabled: !!WebSocketServer }); + log("info", "server_listening", { + hostname, + port, + internalTunnelHost: INTERNAL_TUNNEL_HOST, + wsEnabled: !!WebSocketServer, + }); }); } -main().catch((err) => { - log("error", "server_bootstrap_failed", { error: String(err && err.stack ? err.stack : err) }); - process.exit(1); -}); +// Exposed for tests; not part of the long-lived server entrypoint. +module.exports = { sanitizedRequestPath }; + +if (require.main === module) { + main().catch((err) => { + log("error", "server_bootstrap_failed", { + error: String(err && err.stack ? err.stack : err), + }); + process.exit(1); + }); +} 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 48f2f4610..7b21117a1 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -578,6 +578,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
setEnableOpenaiResponsesWebsocket(checked)} disabled={isPending} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 6b3808e8e..058bdc8a9 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -93,6 +93,37 @@ import { export const DEFAULT_CODEX_USER_AGENT = "codex_cli_rs/0.93.0 (Windows 10.0.26200; x86_64) vscode/1.108.1"; +/** + * Best-effort decode of the *final* outgoing request body into a JSON object. + * + * The Responses WebSocket adapter needs the same payload that would have been + * sent over HTTP — including all filterPrivateParameters() / request-filter + * rewrites. The forwarder represents that body as a `BodyInit` (Buffer, + * string, FormData, ReadableStream, etc.). We only support the byte/string + * shapes here; multipart and stream shapes return null so the caller falls + * back to the HTTP path. + */ +function decodeRequestBodyAsJson(body: BodyInit | undefined): Record | null { + if (body == null) return null; + let text: string | null = null; + if (typeof body === "string") { + text = body; + } else if (Buffer.isBuffer(body)) { + text = body.toString("utf8"); + } else if (body instanceof Uint8Array) { + text = Buffer.from(body).toString("utf8"); + } + if (text == null) return null; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + const OUTBOUND_TRANSPORT_HEADER_BLACKLIST = ["content-length", "connection", "transfer-encoding"]; const RETRY_LIMITS = PROVIDER_LIMITS.MAX_RETRY_ATTEMPTS; @@ -2820,22 +2851,24 @@ export class ProxyForwarder { (init as Record).verbose = true; - // ⭐ OpenAI Responses WebSocket 上游尝试(仅 Codex 供应商 + 开关开启 + 客户端以 WS 接入) + // OpenAI Responses WebSocket 上游尝试(仅 Codex 供应商 + 开关开启 + 客户端以 WS 接入) // 若握手失败或首帧前关闭,降级到下面的 HTTP 路径;不计入熔断器。 let responsesWsResponse: Response | null = null; + const responsesWsEndpointId = endpointAudit?.endpointId ?? null; try { const wsEligibility = await evaluateResponsesWsEligibility({ headers: processedHeaders, provider, - endpointId: null, + endpointId: responsesWsEndpointId, }); if (wsEligibility.eligible) { - const requestMessage = session.request.message; - const requestBodyJson = - requestMessage && typeof requestMessage === "object" - ? (requestMessage as Record) - : null; + // Use the *final* outgoing body so the WS frame matches the HTTP + // path: it has been through filterPrivateParameters() and any + // request-filter transformations. Falling back to + // session.request.message would skip those rewrites and could leak + // private fields or cause the upstream to reject the request. + const requestBodyJson = decodeRequestBodyAsJson(requestBody); if (requestBodyJson) { const wsResult = await tryResponsesWebsocketUpstream({ @@ -2851,25 +2884,31 @@ export class ProxyForwarder { logger.info("ProxyForwarder: Upstream Responses WebSocket connected", { providerId: provider.id, providerName: provider.name, + endpointId: responsesWsEndpointId, connected: wsResult.connected, }); session.addProviderToChain(provider, { reason: "responses_ws_attempted", + endpointId: responsesWsEndpointId, + endpointUrl: endpointAudit?.endpointUrl, attemptNumber: undefined, }); } else { - markResponsesWsUnsupported(provider.id, null, wsResult.reason); + markResponsesWsUnsupported(provider.id, responsesWsEndpointId, wsResult.reason); logger.info( "ProxyForwarder: Upstream Responses WebSocket unavailable, falling back to HTTP", { providerId: provider.id, providerName: provider.name, + endpointId: responsesWsEndpointId, reason: wsResult.reason, message: wsResult.message, } ); session.addProviderToChain(provider, { reason: "responses_ws_fallback", + endpointId: responsesWsEndpointId, + endpointUrl: endpointAudit?.endpointUrl, errorMessage: wsResult.message, attemptNumber: undefined, }); @@ -2878,6 +2917,8 @@ export class ProxyForwarder { } else if (wsEligibility.isWebsocketClient && wsEligibility.downgradeReason) { session.addProviderToChain(provider, { reason: "responses_ws_fallback", + endpointId: responsesWsEndpointId, + endpointUrl: endpointAudit?.endpointUrl, errorMessage: wsEligibility.downgradeReason, attemptNumber: undefined, }); diff --git a/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts b/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts index 720a217f5..f0502bc0b 100644 --- a/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts +++ b/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts @@ -64,6 +64,15 @@ describe("isWebsocketClientRequest", () => { isWebsocketClientRequest({ [CLIENT_TRANSPORT_HEADER]: "http" } as Record) ).toBe(false); }); + + it("handles record keys regardless of case (HTTP header semantics)", () => { + expect( + isWebsocketClientRequest({ "X-Cch-Client-Transport": "websocket" } as Record) + ).toBe(true); + expect( + isWebsocketClientRequest({ "X-CCH-Client-TRANSPORT": "WEBSOCKET" } as Record) + ).toBe(true); + }); }); describe("evaluateResponsesWsEligibility", () => { diff --git a/src/app/v1/_lib/responses-ws/__tests__/server-helpers.test.ts b/src/app/v1/_lib/responses-ws/__tests__/server-helpers.test.ts new file mode 100644 index 000000000..f245e05fa --- /dev/null +++ b/src/app/v1/_lib/responses-ws/__tests__/server-helpers.test.ts @@ -0,0 +1,34 @@ +// Co-located smoke test for the small helpers exported from `server.js`. We +// only validate the log-sanitization helper here; the full custom server is +// integration-tested separately. +import { describe, expect, it } from "vitest"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { sanitizedRequestPath } = require("../../../../../../server.js") as { + sanitizedRequestPath: (rawUrl: string) => string; +}; + +describe("server.js sanitizedRequestPath", () => { + it("returns the path unchanged when there is no query string", () => { + expect(sanitizedRequestPath("/v1/responses")).toBe("/v1/responses"); + }); + + it("preserves the model query parameter (allow-listed)", () => { + expect(sanitizedRequestPath("/v1/responses?model=gpt-5")).toBe("/v1/responses?model=gpt-5"); + }); + + it("masks unknown / sensitive query parameters", () => { + const out = sanitizedRequestPath("/v1/responses?api_key=sk-secret&token=abc&user=alice"); + expect(out).toContain("api_key=***"); + expect(out).toContain("token=***"); + expect(out).toContain("user=***"); + expect(out).not.toContain("sk-secret"); + expect(out).not.toContain("alice"); + }); + + it("falls back to root when the URL is unparseable", () => { + // `new URL` accepts most inputs against http://localhost; pass an obvious + // non-string sentinel to trigger the catch branch. + expect(sanitizedRequestPath(undefined as unknown as string)).toBe("/"); + }); +}); diff --git a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts index d7ebcae40..f10f9a08c 100644 --- a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts +++ b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts @@ -194,4 +194,78 @@ describe("tryResponsesWebsocketUpstream", () => { expect((receivedFrame as Record).background).toBeUndefined(); expect((receivedFrame as Record).store).toBe(false); }); + + it("filters hop-by-hop and shape headers regardless of input shape", async () => { + let receivedHeaders: Record = {}; + server = await startMockServer((socket, req) => { + receivedHeaders = req.headers as Record; + socket.on("message", () => { + socket.send(JSON.stringify({ type: "response.completed", response: { id: "x" } })); + }); + }); + + const plainHeaders: Record = { + authorization: "Bearer sk-mock", + // These must be filtered out regardless of the input shape: + connection: "keep-alive", + host: "evil.example.com", + "content-length": "999", + "transfer-encoding": "chunked", + accept: "application/json", + "content-type": "application/json", + // Custom header should pass through: + "x-cch-tenant": "tenant-a", + }; + + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: plainHeaders, + body: { model: "gpt-5", input: "hi" }, + }); + + expect("response" in result).toBe(true); + if (!("response" in result)) return; + await collectSseBody(result.response); + + expect(receivedHeaders.authorization).toBe("Bearer sk-mock"); + expect(receivedHeaders["x-cch-tenant"]).toBe("tenant-a"); + // The host the upstream observed must come from the actual TCP target, + // never the value we passed in the plain Record (which we filter): + expect(receivedHeaders.host).not.toBe("evil.example.com"); + // ws-package-managed headers must be set by ws, not echoed from input: + expect(receivedHeaders["content-length"]).not.toBe("999"); + }); + + it("emits an error frame when upstream WS fails mid-stream after the first event", async () => { + server = await startMockServer((socket) => { + socket.on("message", () => { + socket.send(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })); + // Simulate an abrupt protocol-level failure; no terminal event is sent. + setTimeout(() => { + try { + socket.terminate(); + } catch { + // ignore + } + }, 5); + }); + }); + + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { model: "gpt-5", input: "hi" }, + }); + + expect("response" in result).toBe(true); + if (!("response" in result)) return; + const body = await collectSseBody(result.response); + expect(body).toContain('"type":"response.created"'); + // The mid-stream failure must surface as an error event so the downstream + // pipeline does not mistake the truncated stream for a clean success. + expect(body).toContain('"type":"error"'); + expect(body).toContain("upstream_ws_mid_stream_error"); + }); }); diff --git a/src/app/v1/_lib/responses-ws/eligibility.ts b/src/app/v1/_lib/responses-ws/eligibility.ts index 3bc57081b..ae4c0e9c4 100644 --- a/src/app/v1/_lib/responses-ws/eligibility.ts +++ b/src/app/v1/_lib/responses-ws/eligibility.ts @@ -33,10 +33,19 @@ export interface ResponsesWsEligibility { } export function isWebsocketClientRequest(headers: Headers | Record): boolean { - const value = - headers instanceof Headers - ? headers.get(CLIENT_TRANSPORT_HEADER) - : (headers[CLIENT_TRANSPORT_HEADER] as string | undefined); + let value: string | null | undefined; + if (headers instanceof Headers) { + value = headers.get(CLIENT_TRANSPORT_HEADER); + } else { + // Plain record: header keys may be in any case (e.g. `X-Cch-Client-Transport`). + // Normalize to lowercase before comparing to avoid silent misses. + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === CLIENT_TRANSPORT_HEADER) { + value = v; + break; + } + } + } return typeof value === "string" && value.toLowerCase() === "websocket"; } diff --git a/src/app/v1/_lib/responses-ws/upstream-adapter.ts b/src/app/v1/_lib/responses-ws/upstream-adapter.ts index e9887bf13..a394d0bc1 100644 --- a/src/app/v1/_lib/responses-ws/upstream-adapter.ts +++ b/src/app/v1/_lib/responses-ws/upstream-adapter.ts @@ -48,6 +48,29 @@ const TERMINAL_EVENT_TYPES = new Set([ ]); const HANDSHAKE_TIMEOUT_MS = 10_000; +// `handshakeTimeout` only covers the HTTP -> WS upgrade. Once upgrade +// succeeds, an upstream may still hang without sending any event (bug, dead +// connection, half-open socket). Without a separate first-event timer the +// `await openPromise` below would hang forever and tie up the request slot. +const FIRST_EVENT_TIMEOUT_MS = 20_000; + +// Hop-by-hop and request-shape headers that must NOT be forwarded into the +// outbound WebSocket upgrade. The `ws` package handles Connection / +// Upgrade / Sec-WebSocket-* itself; the body-shape headers belong to HTTP +// only and would either be ignored or cause handshake rejection. +const FORBIDDEN_UPSTREAM_WS_HEADERS = new Set([ + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + "host", + "content-length", + "transfer-encoding", + "accept", + "content-type", +]); function toWsUrl(httpUrl: string): string { const url = new URL(httpUrl); @@ -62,6 +85,20 @@ function stripTransportOnlyFields>(body: T): T return copy as T; } +function buildUpstreamWsHeaders(source: Headers | Record): Record { + const out: Record = {}; + const push = (key: string, value: string) => { + if (FORBIDDEN_UPSTREAM_WS_HEADERS.has(key.toLowerCase())) return; + out[key] = value; + }; + if (source instanceof Headers) { + source.forEach((value, key) => push(key, value)); + } else { + for (const [k, v] of Object.entries(source)) push(k, v); + } + return out; +} + async function loadWsModule(): Promise { try { const mod = await import("ws"); @@ -89,33 +126,7 @@ export async function tryResponsesWebsocketUpstream(options: { } const wssUrl = toWsUrl(options.upstreamUrl); - const headers: Record = {}; - if (options.upstreamHeaders instanceof Headers) { - options.upstreamHeaders.forEach((value, key) => { - const lower = key.toLowerCase(); - // ws package handles Connection/Upgrade/Sec-WebSocket-* itself. - if ( - lower === "connection" || - lower === "upgrade" || - lower === "sec-websocket-key" || - lower === "sec-websocket-version" || - lower === "sec-websocket-extensions" || - lower === "sec-websocket-protocol" || - lower === "host" || - lower === "content-length" || - lower === "transfer-encoding" || - lower === "accept" || - lower === "content-type" - ) { - return; - } - headers[key] = value; - }); - } else { - for (const [k, v] of Object.entries(options.upstreamHeaders)) { - headers[k] = v; - } - } + const headers = buildUpstreamWsHeaders(options.upstreamHeaders); const frame = { type: "response.create", @@ -191,12 +202,20 @@ export async function tryResponsesWebsocketUpstream(options: { const messageQueue: string[] = []; let queueResolver: ((value: string | null) => void) | null = null; let closed = false; - let closeReason: UpstreamWsFallbackReason | null = null; + // Marks an upstream failure observed AFTER the first event was emitted. + // The downstream pipeline must see this as an error rather than a clean + // end-of-stream so it doesn't treat a half-streamed response as success. + let midStreamError: { code: string; message?: string } | null = null; + let firstEventTimer: ReturnType | null = null; ws.on("message", (data: Buffer | string) => { const text = typeof data === "string" ? data : data.toString("utf8"); if (!firstEventSeen) { firstEventSeen = true; + if (firstEventTimer) { + clearTimeout(firstEventTimer); + firstEventTimer = null; + } finishOpen({ ok: true }); } if (queueResolver) { @@ -219,9 +238,13 @@ export async function tryResponsesWebsocketUpstream(options: { reason: "ws_error_pre_first_event", message: String(err?.message ? err.message : err), }); + } else { + midStreamError = { + code: "upstream_ws_mid_stream_error", + message: String(err?.message ? err.message : err), + }; } closed = true; - closeReason = firstEventSeen ? null : "ws_error_pre_first_event"; if (queueResolver) { const resolve = queueResolver; queueResolver = null; @@ -258,7 +281,28 @@ export async function tryResponsesWebsocketUpstream(options: { ); } + // Bound the wait for the first event so a silent upstream cannot pin a + // request slot indefinitely. Cleared on first message or any other + // resolution. + firstEventTimer = setTimeout(() => { + if (firstEventSeen) return; + finishOpen({ + ok: false, + reason: "ws_error_pre_first_event", + message: "timeout_waiting_for_first_event", + }); + try { + ws.close(1011); + } catch { + // ignore + } + }, FIRST_EVENT_TIMEOUT_MS); + const openResult = await openPromise; + if (firstEventTimer) { + clearTimeout(firstEventTimer); + firstEventTimer = null; + } if (!openResult.ok) { try { ws.terminate?.(); @@ -274,6 +318,8 @@ export async function tryResponsesWebsocketUpstream(options: { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { + let sawTerminalEvent = false; + const writeLine = (obj: string) => { controller.enqueue(encoder.encode(`data: ${obj}\n\n`)); }; @@ -283,6 +329,7 @@ export async function tryResponsesWebsocketUpstream(options: { try { const parsed = JSON.parse(text); if (parsed && typeof parsed.type === "string" && TERMINAL_EVENT_TYPES.has(parsed.type)) { + sawTerminalEvent = true; return true; } } catch { @@ -326,10 +373,31 @@ export async function tryResponsesWebsocketUpstream(options: { } } - if (closeReason === "ws_error_pre_first_event") { - // Shouldn't happen: we only reach here if firstEventSeen=true. - controller.error(new Error("upstream_ws_mid_stream_error")); - return; + // Drain any messages enqueued after the loop's last `await` resolved + // with `null` (race between shift() and `closed` becoming true). + while (messageQueue.length > 0) { + const msg = messageQueue.shift(); + if (msg === undefined) break; + if (processText(msg)) { + controller.close(); + return; + } + } + + // If the upstream WS hung up before sending a terminal event, the + // downstream pipeline must see this as an error rather than a clean + // end-of-stream — otherwise a truncated body would be billed as a + // successful response. + if (!sawTerminalEvent) { + const failure = midStreamError ?? { + code: "upstream_ws_mid_stream_error", + message: "upstream WebSocket closed before emitting a terminal response event", + }; + const errorFrame = JSON.stringify({ + type: "error", + error: failure, + }); + controller.enqueue(encoder.encode(`data: ${errorFrame}\n\n`)); } controller.close(); diff --git a/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx b/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx index 461330d6e..ca507ddf8 100644 --- a/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-filter-gating.test.tsx @@ -46,7 +46,7 @@ vi.mock("@/app/[locale]/dashboard/leaderboard/_components/leaderboard-table", () })); vi.mock("@/components/ui/tag-input", () => ({ - TagInput: ({ ["data-testid"]: testId }: { "data-testid"?: string }) => ( + TagInput: ({ "data-testid": testId }: { "data-testid"?: string }) => (
), })); diff --git a/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx b/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx index 3451fed9e..870560670 100644 --- a/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx +++ b/tests/unit/dashboard/leaderboard-view-tab-grouping.test.tsx @@ -46,7 +46,7 @@ vi.mock("@/app/[locale]/dashboard/leaderboard/_components/leaderboard-table", () })); vi.mock("@/components/ui/tag-input", () => ({ - TagInput: ({ ["data-testid"]: testId }: { "data-testid"?: string }) => ( + TagInput: ({ "data-testid": testId }: { "data-testid"?: string }) => (
), })); From ec98ee617595589dd5c49f62ba5c194170892078 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 25 Apr 2026 12:49:04 +0800 Subject: [PATCH 03/47] fix(proxy): harden responses websocket against header spoofing + scope WS bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address second-round PR review: Header spoofing (P1): the `x-cch-client-transport` marker was previously trusted from any inbound HTTP caller, so an external client could `curl` /v1/responses with that header and trick the forwarder into attempting an upstream WebSocket dial. Fixed in three layers: - New `responses-ws/internal-secret.ts` exposes a per-process random secret (env-overridable) plus `verifyInternalRequest()`. - `server.js` strips ALL inbound `x-cch-*` headers from the client WS request before tunneling to the internal HTTP loopback, then sets the internal markers (`x-cch-client-transport`, `x-cch-responses-ws-forward`, `x-cch-internal-secret`) itself. The secret is generated at server startup and lives only in this process. - `eligibility.ts#isWebsocketClientRequest` now requires both the public marker AND a successful `verifyInternalRequest()`, so spoofed external requests fall straight to the HTTP path with `isWebsocketClient: false`. WebSocket frame size: `WebSocketServer` now uses `maxPayload: 1 MiB` (default is 100 MiB) — public WS endpoints should not accept arbitrarily large frames. Upstream queue bounds: the upstream adapter caps buffered upstream bytes at 8 MiB. If the SSE consumer stalls or the upstream blasts faster than we can drain, we tear down the WS and emit a synthetic `upstream_ws_queue_overflow` error rather than growing the heap unbounded. The existing pop path now also decrements the byte counter. Unsupported-cache scoping: `UpstreamWsFailure` carries a new `cacheableAsUnsupported` flag. We only mark an endpoint as WS-unsupported when the upgrade is rejected with HTTP 400/404/405/426/501 — transient failures (401/403, 5xx, network errors, silent upstream, first-event timeout, mid-handshake aborts) re-probe on the next request. The forwarder honors the flag. Tracing slim-down: `outputFileTracingIncludes` no longer pulls in the entire `node_modules/next/**/*` tree. Only `next/dist/**/*` and `next/package.json` (plus `ws/**/*`) are explicitly included; everything else is left to Next's static analysis. Tests: - `internal-secret.test.ts`: full coverage of generate/honor/verify paths, including missing/wrong/case-mixed headers. - `eligibility.test.ts`: dedicated spoofing-prevention cases (no secret, wrong secret, missing forward flag) all return `isWebsocketClient:false`, not a WS-downgrade. - `upstream-adapter.test.ts`: `previous_response_id` + `store=false` continuity across two consecutive turns; HTTP 426/404/501 classified as cacheable-unsupported; HTTP 401/503 classified as NOT cacheable. Co-Authored-By: Claude Opus 4.7 (1M context) --- next.config.ts | 12 +- server.js | 41 +++++- src/app/v1/_lib/proxy/forwarder.ts | 10 +- .../__tests__/eligibility.test.ts | 137 +++++++++++++++--- .../__tests__/internal-secret.test.ts | 102 +++++++++++++ .../__tests__/upstream-adapter.test.ts | 132 +++++++++++++++++ src/app/v1/_lib/responses-ws/eligibility.ts | 16 +- .../v1/_lib/responses-ws/internal-secret.ts | 116 +++++++++++++++ .../v1/_lib/responses-ws/upstream-adapter.ts | 116 ++++++++++++--- 9 files changed, 637 insertions(+), 45 deletions(-) create mode 100644 src/app/v1/_lib/responses-ws/__tests__/internal-secret.test.ts create mode 100644 src/app/v1/_lib/responses-ws/internal-secret.ts diff --git a/next.config.ts b/next.config.ts index 01769dd4c..4049e7c97 100644 --- a/next.config.ts +++ b/next.config.ts @@ -30,10 +30,16 @@ const nextConfig: NextConfig = { "/**": [ "./node_modules/undici/**/*", "./node_modules/fetch-socks/**/*", - // 自定义 Node 服务器使用 next 的 programmatic API 与 ws 处理 WebSocket 升级, - // 需要强制追踪这两个包到 standalone 输出。 - "./node_modules/next/**/*", + // 自定义 Node 服务器(server.js)只用到 `ws` 与 next 的入口; + // 让 Next 的依赖追踪决定从 next 包里收纳什么文件,避免把 next 整个 + // node_modules 都拖进 standalone 产物(约数十 MB)。仅显式追加: + // - ws:standalone 默认追踪基于 import 静态分析,server.js 是 CJS + // 根入口,未被 Next 编译,必须手工列出。 + // - next/dist:自定义 server 通过 require("next") 进入;保留 dist + // 子树确保 programmatic API 可用。 "./node_modules/ws/**/*", + "./node_modules/next/dist/**/*", + "./node_modules/next/package.json", ], }, diff --git a/server.js b/server.js index 1a94c0e61..29d070a6d 100644 --- a/server.js +++ b/server.js @@ -22,6 +22,7 @@ "use strict"; const http = require("node:http"); +const { randomUUID } = require("node:crypto"); const { parse } = require("node:url"); const dev = process.env.NODE_ENV !== "production"; @@ -38,12 +39,25 @@ const INTERNAL_TUNNEL_HOST = const WS_PATH = "/v1/responses"; const CLIENT_TRANSPORT_HEADER = "x-cch-client-transport"; const WS_FORWARD_FLAG_HEADER = "x-cch-responses-ws-forward"; +const INTERNAL_SECRET_HEADER = "x-cch-internal-secret"; +const INTERNAL_SECRET_ENV = "CCH_RESPONSES_WS_INTERNAL_SECRET"; + +// Header names a client must NEVER be allowed to set on inbound traffic. +// Anything starting with "x-cch-" is reserved for internal markers; the WS +// edge strips the entire prefix from inbound requests so an attacker cannot +// pre-set the WS-tunnel marker headers when they connect. +const RESERVED_INTERNAL_HEADER_PREFIX = "x-cch-"; // Per-WebSocket-connection guardrails: cap the queue depth and total queued // bytes to make a misbehaving / malicious client a bounded-memory event. const MAX_PENDING_FRAMES = 64; const MAX_PENDING_BYTES = 4 * 1024 * 1024; // 4 MiB across all queued frames +// Maximum payload size for any single inbound WS frame. The default `ws` +// limit is 100 MiB, far larger than a Responses create body needs to be and +// dangerous for a public endpoint. +const WS_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024; // 1 MiB per frame + const TERMINAL_EVENT_TYPES = new Set([ "response.completed", "response.failed", @@ -262,8 +276,8 @@ async function handleWebSocketConnection(ws, req) { async function forwardToInternalHttp(ws, originalReq, body, registerInternalReq) { const internalHeaders = {}; for (const [k, v] of Object.entries(originalReq.headers)) { - // Skip hop-by-hop / WS-specific headers; keep app-level auth/session etc. const lower = k.toLowerCase(); + // Strip hop-by-hop / WS-specific transport headers. if ( lower === "host" || lower === "connection" || @@ -277,6 +291,13 @@ async function forwardToInternalHttp(ws, originalReq, body, registerInternalReq) ) { continue; } + // Strip any `x-cch-*` header the client may have set: those names are + // reserved for internal markers that we'll attach below. Without this an + // external attacker could try to forge `x-cch-internal-secret` / + // `x-cch-responses-ws-forward` and bypass the loopback-only check. + if (lower.startsWith(RESERVED_INTERNAL_HEADER_PREFIX)) { + continue; + } if (Array.isArray(v)) { internalHeaders[k] = v.join(", "); } else if (typeof v === "string") { @@ -287,6 +308,14 @@ async function forwardToInternalHttp(ws, originalReq, body, registerInternalReq) internalHeaders["content-type"] = "application/json"; internalHeaders[CLIENT_TRANSPORT_HEADER] = "websocket"; internalHeaders[WS_FORWARD_FLAG_HEADER] = "1"; + // Per-process loopback secret. Read from process.env so it can be picked + // up by any code path that needs to verify (the TS forwarder reads the + // same env var via `internal-secret.ts`). The secret is generated at + // startup if no operator value is preset. + const internalSecret = process.env[INTERNAL_SECRET_ENV]; + if (internalSecret) { + internalHeaders[INTERNAL_SECRET_HEADER] = internalSecret; + } // Force streaming so we can translate SSE events to WS frames incrementally. // The upstream pipeline will strip transport-only fields (stream, background) @@ -486,6 +515,14 @@ async function main() { WebSocketServer = null; } + // Initialize the per-process internal secret BEFORE next.prepare() so that + // any module loaded by Next can read the same value from process.env. + // Operators may pre-seed the env var; otherwise we generate one. Either + // way the secret never leaves this process. + if (!process.env[INTERNAL_SECRET_ENV]) { + process.env[INTERNAL_SECRET_ENV] = randomUUID(); + } + const app = nextFactory({ dev, hostname, port }); const handler = app.getRequestHandler(); await app.prepare(); @@ -506,7 +543,7 @@ async function main() { }); if (WebSocketServer) { - const wss = new WebSocketServer({ noServer: true }); + const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD_BYTES }); server.on("upgrade", (req, socket, head) => { if (!isResponsesWsUpgrade(req)) { diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 058bdc8a9..42e31ad2d 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -2894,7 +2894,14 @@ export class ProxyForwarder { attemptNumber: undefined, }); } else { - markResponsesWsUnsupported(provider.id, responsesWsEndpointId, wsResult.reason); + // Only cache when the failure proves the endpoint does not + // speak the WS protocol (HTTP 4xx / 501 on the upgrade). Any + // transient failure (network, auth, silent upstream) should + // re-probe on the next request rather than skipping WS for + // the full TTL. + if (wsResult.cacheableAsUnsupported) { + markResponsesWsUnsupported(provider.id, responsesWsEndpointId, wsResult.reason); + } logger.info( "ProxyForwarder: Upstream Responses WebSocket unavailable, falling back to HTTP", { @@ -2902,6 +2909,7 @@ export class ProxyForwarder { providerName: provider.name, endpointId: responsesWsEndpointId, reason: wsResult.reason, + cacheable: wsResult.cacheableAsUnsupported, message: wsResult.message, } ); diff --git a/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts b/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts index f0502bc0b..0d8298efd 100644 --- a/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts +++ b/src/app/v1/_lib/responses-ws/__tests__/eligibility.test.ts @@ -1,10 +1,15 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { Provider } from "@/types/provider"; import { CLIENT_TRANSPORT_HEADER, evaluateResponsesWsEligibility, isWebsocketClientRequest, } from "../eligibility"; +import { + ensureInternalSecret, + INTERNAL_SECRET_HEADER, + WS_FORWARD_FLAG_HEADER, +} from "../internal-secret"; import { clearResponsesWsUnsupportedCache, markResponsesWsUnsupported } from "../unsupported-cache"; const isOpenaiResponsesWebsocketEnabledMock = vi.fn(); @@ -12,6 +17,25 @@ vi.mock("@/lib/config/system-settings-cache", () => ({ isOpenaiResponsesWebsocketEnabled: () => isOpenaiResponsesWebsocketEnabledMock(), })); +let TEST_SECRET = ""; +const originalSecret = process.env.CCH_RESPONSES_WS_INTERNAL_SECRET; + +beforeAll(() => { + // Tests run in the same Node process; the eligibility check verifies + // against `process.env.CCH_RESPONSES_WS_INTERNAL_SECRET`. Pin a known + // value so tests are deterministic. + process.env.CCH_RESPONSES_WS_INTERNAL_SECRET = "test-loopback-secret"; + TEST_SECRET = ensureInternalSecret(); +}); + +afterAll(() => { + if (originalSecret === undefined) { + delete process.env.CCH_RESPONSES_WS_INTERNAL_SECRET; + } else { + process.env.CCH_RESPONSES_WS_INTERNAL_SECRET = originalSecret; + } +}); + function codexProvider(id = 1): Provider { return { id, @@ -25,7 +49,6 @@ function codexProvider(id = 1): Provider { costMultiplier: 1, groupTag: null, providerVendorId: null, - // minimum required shape for our code path; other fields are unused here } as unknown as Provider; } @@ -45,32 +68,80 @@ function claudeProvider(id = 2): Provider { } as unknown as Provider; } +/** + * Build a request that LOOKS like the trusted internal tunnel: it has the + * client-transport marker AND the per-process secret AND the forward flag. + * Use this whenever you want eligibility to behave as for a true WS request. + */ +function trustedInternalHeaders(extra?: Record): Headers { + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + h.set(INTERNAL_SECRET_HEADER, TEST_SECRET); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + if (extra) { + for (const [k, v] of Object.entries(extra)) h.set(k, v); + } + return h; +} + describe("isWebsocketClientRequest", () => { - it("detects websocket via Headers object", () => { + it("treats request with client-transport + valid secret + forward flag as a WS client", () => { + expect(isWebsocketClientRequest(trustedInternalHeaders())).toBe(true); + }); + + it("rejects requests with client-transport but no internal secret (spoofing attempt)", () => { const h = new Headers(); h.set(CLIENT_TRANSPORT_HEADER, "websocket"); - expect(isWebsocketClientRequest(h)).toBe(true); + expect(isWebsocketClientRequest(h)).toBe(false); }); - it("detects websocket via plain record", () => { - expect( - isWebsocketClientRequest({ [CLIENT_TRANSPORT_HEADER]: "WebSocket" } as Record) - ).toBe(true); + it("rejects requests with client-transport + forward flag but no secret", () => { + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + expect(isWebsocketClientRequest(h)).toBe(false); + }); + + it("rejects requests with a wrong internal secret", () => { + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + h.set(INTERNAL_SECRET_HEADER, "wrong-secret"); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + expect(isWebsocketClientRequest(h)).toBe(false); + }); + + it("rejects requests with a valid secret but no forward flag", () => { + const h = new Headers(); + h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + h.set(INTERNAL_SECRET_HEADER, TEST_SECRET); + expect(isWebsocketClientRequest(h)).toBe(false); }); it("returns false when header is absent or other transport", () => { expect(isWebsocketClientRequest({})).toBe(false); expect( - isWebsocketClientRequest({ [CLIENT_TRANSPORT_HEADER]: "http" } as Record) + isWebsocketClientRequest({ + [CLIENT_TRANSPORT_HEADER]: "http", + [INTERNAL_SECRET_HEADER]: TEST_SECRET, + [WS_FORWARD_FLAG_HEADER]: "1", + } as Record) ).toBe(false); }); it("handles record keys regardless of case (HTTP header semantics)", () => { expect( - isWebsocketClientRequest({ "X-Cch-Client-Transport": "websocket" } as Record) + isWebsocketClientRequest({ + "X-Cch-Client-Transport": "websocket", + "X-Cch-Internal-Secret": TEST_SECRET, + "X-Cch-Responses-Ws-Forward": "1", + } as Record) ).toBe(true); expect( - isWebsocketClientRequest({ "X-CCH-Client-TRANSPORT": "WEBSOCKET" } as Record) + isWebsocketClientRequest({ + "X-CCH-Client-TRANSPORT": "WEBSOCKET", + "X-CCH-INTERNAL-SECRET": TEST_SECRET, + "X-CCH-Responses-WS-Forward": "1", + } as Record) ).toBe(true); }); }); @@ -91,12 +162,42 @@ describe("evaluateResponsesWsEligibility", () => { expect(result).toEqual({ isWebsocketClient: false, eligible: false }); }); - it("records provider_not_codex for non-codex upstreams", async () => { + it("treats spoofed requests (no internal secret) as non-WS clients (HTTP path)", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); + const spoofed = new Headers(); + spoofed.set(CLIENT_TRANSPORT_HEADER, "websocket"); + spoofed.set(WS_FORWARD_FLAG_HEADER, "1"); + // intentionally no INTERNAL_SECRET_HEADER + + const result = await evaluateResponsesWsEligibility({ + headers: spoofed, + provider: codexProvider(), + endpointId: null, + }); + // Spoofing must NOT be reported as a "ws downgrade" — there was never a + // legitimate WS client. Return the same shape as a regular HTTP request. + expect(result).toEqual({ isWebsocketClient: false, eligible: false }); + }); + + it("treats requests with a wrong internal secret as non-WS clients", async () => { isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); const h = new Headers(); h.set(CLIENT_TRANSPORT_HEADER, "websocket"); + h.set(INTERNAL_SECRET_HEADER, "definitely-not-the-secret"); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + const result = await evaluateResponsesWsEligibility({ headers: h, + provider: codexProvider(), + endpointId: null, + }); + expect(result).toEqual({ isWebsocketClient: false, eligible: false }); + }); + + it("records provider_not_codex for non-codex upstreams", async () => { + isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); + const result = await evaluateResponsesWsEligibility({ + headers: trustedInternalHeaders(), provider: claudeProvider(), endpointId: null, }); @@ -107,10 +208,8 @@ describe("evaluateResponsesWsEligibility", () => { it("records setting_disabled when global toggle is off", async () => { isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(false); - const h = new Headers(); - h.set(CLIENT_TRANSPORT_HEADER, "websocket"); const result = await evaluateResponsesWsEligibility({ - headers: h, + headers: trustedInternalHeaders(), provider: codexProvider(), endpointId: null, }); @@ -123,10 +222,8 @@ describe("evaluateResponsesWsEligibility", () => { isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); const provider = codexProvider(99); markResponsesWsUnsupported(provider.id, null, "ws_upgrade_rejected"); - const h = new Headers(); - h.set(CLIENT_TRANSPORT_HEADER, "websocket"); const result = await evaluateResponsesWsEligibility({ - headers: h, + headers: trustedInternalHeaders(), provider, endpointId: null, }); @@ -136,10 +233,8 @@ describe("evaluateResponsesWsEligibility", () => { it("returns eligible when all conditions are met", async () => { isOpenaiResponsesWebsocketEnabledMock.mockResolvedValue(true); - const h = new Headers(); - h.set(CLIENT_TRANSPORT_HEADER, "websocket"); const result = await evaluateResponsesWsEligibility({ - headers: h, + headers: trustedInternalHeaders(), provider: codexProvider(), endpointId: null, }); diff --git a/src/app/v1/_lib/responses-ws/__tests__/internal-secret.test.ts b/src/app/v1/_lib/responses-ws/__tests__/internal-secret.test.ts new file mode 100644 index 000000000..d078c889b --- /dev/null +++ b/src/app/v1/_lib/responses-ws/__tests__/internal-secret.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + ensureInternalSecret, + getInternalSecret, + INTERNAL_SECRET_HEADER, + RESERVED_INTERNAL_HEADERS, + verifyInternalRequest, + WS_FORWARD_FLAG_HEADER, +} from "../internal-secret"; + +const ENV_VAR = "CCH_RESPONSES_WS_INTERNAL_SECRET"; + +describe("internal-secret", () => { + let originalSecret: string | undefined; + + beforeEach(() => { + originalSecret = process.env[ENV_VAR]; + delete process.env[ENV_VAR]; + }); + + afterEach(() => { + if (originalSecret === undefined) { + delete process.env[ENV_VAR]; + } else { + process.env[ENV_VAR] = originalSecret; + } + }); + + it("returns null when the secret has not been initialized", () => { + expect(getInternalSecret()).toBeNull(); + }); + + it("ensureInternalSecret generates a UUID when none is preset", () => { + const secret = ensureInternalSecret(); + expect(secret).toMatch(/^[0-9a-f-]{36}$/); + expect(getInternalSecret()).toBe(secret); + }); + + it("ensureInternalSecret honors a pre-set value", () => { + process.env[ENV_VAR] = "operator-supplied-secret"; + expect(ensureInternalSecret()).toBe("operator-supplied-secret"); + }); + + it("ensureInternalSecret is idempotent", () => { + const a = ensureInternalSecret(); + const b = ensureInternalSecret(); + expect(a).toBe(b); + }); + + it("verifyInternalRequest rejects when no secret is configured", () => { + const h = new Headers(); + h.set(INTERNAL_SECRET_HEADER, "anything"); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + expect(verifyInternalRequest(h)).toBe(false); + }); + + it("verifyInternalRequest rejects when the secret header is missing", () => { + process.env[ENV_VAR] = "real-secret"; + const h = new Headers(); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + expect(verifyInternalRequest(h)).toBe(false); + }); + + it("verifyInternalRequest rejects when the secret is wrong", () => { + process.env[ENV_VAR] = "real-secret"; + const h = new Headers(); + h.set(INTERNAL_SECRET_HEADER, "spoofed-secret"); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + expect(verifyInternalRequest(h)).toBe(false); + }); + + it("verifyInternalRequest rejects when the forward flag is missing", () => { + process.env[ENV_VAR] = "real-secret"; + const h = new Headers(); + h.set(INTERNAL_SECRET_HEADER, "real-secret"); + expect(verifyInternalRequest(h)).toBe(false); + }); + + it("verifyInternalRequest accepts when secret + forward flag are correct", () => { + process.env[ENV_VAR] = "real-secret"; + const h = new Headers(); + h.set(INTERNAL_SECRET_HEADER, "real-secret"); + h.set(WS_FORWARD_FLAG_HEADER, "1"); + expect(verifyInternalRequest(h)).toBe(true); + }); + + it("verifyInternalRequest works with plain Record regardless of case", () => { + process.env[ENV_VAR] = "real-secret"; + expect( + verifyInternalRequest({ + "X-Cch-Internal-Secret": "real-secret", + "X-Cch-Responses-Ws-Forward": "1", + }) + ).toBe(true); + }); + + it("RESERVED_INTERNAL_HEADERS lists the secret + forward flag + transport markers", () => { + expect(RESERVED_INTERNAL_HEADERS).toContain(INTERNAL_SECRET_HEADER); + expect(RESERVED_INTERNAL_HEADERS).toContain(WS_FORWARD_FLAG_HEADER); + expect(RESERVED_INTERNAL_HEADERS).toContain("x-cch-client-transport"); + }); +}); diff --git a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts index f10f9a08c..2972cf929 100644 --- a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts +++ b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts @@ -237,6 +237,138 @@ describe("tryResponsesWebsocketUpstream", () => { expect(receivedHeaders["content-length"]).not.toBe("999"); }); + it("preserves store=false and previous_response_id across continuous WS turns", async () => { + const receivedFrames: Array> = []; + server = await startMockServer((socket) => { + let turn = 0; + socket.on("message", (data) => { + try { + receivedFrames.push(JSON.parse(data.toString("utf8")) as Record); + } catch { + // ignore + } + const responseId = `resp_${++turn}`; + socket.send( + JSON.stringify({ + type: "response.created", + response: { id: responseId }, + }) + ); + socket.send( + JSON.stringify({ + type: "response.completed", + response: { id: responseId, prompt_cache_key: "tenantA:s1" }, + }) + ); + }); + }); + + // Turn 1: full input, store=false, no previous_response_id yet. + const turn1 = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { + model: "gpt-5", + store: false, + prompt_cache_key: "tenantA:s1", + input: [{ role: "user", content: "hello" }], + }, + }); + expect("response" in turn1).toBe(true); + if (!("response" in turn1)) return; + await collectSseBody(turn1.response); + + // Turn 2: send only the new input + previous_response_id, with store=false + // preserved. The adapter must forward both fields untouched so the + // upstream can re-use its in-connection state. + const turn2 = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { + model: "gpt-5", + store: false, + prompt_cache_key: "tenantA:s1", + previous_response_id: "resp_1", + input: [ + { + type: "function_call_output", + call_id: "call_1", + output: '{"ok":true}', + }, + ], + }, + }); + expect("response" in turn2).toBe(true); + if (!("response" in turn2)) return; + await collectSseBody(turn2.response); + + expect(receivedFrames).toHaveLength(2); + const [first, second] = receivedFrames; + expect(first.type).toBe("response.create"); + expect(first.store).toBe(false); + expect(first.prompt_cache_key).toBe("tenantA:s1"); + expect(first.previous_response_id).toBeUndefined(); + + expect(second.type).toBe("response.create"); + expect(second.store).toBe(false); + expect(second.previous_response_id).toBe("resp_1"); + expect(second.prompt_cache_key).toBe("tenantA:s1"); + // input was passed through untouched + expect(Array.isArray(second.input)).toBe(true); + }); + + it("classifies HTTP 426 / 404 / 501 upgrade failures as cacheable-unsupported", async () => { + const http = await import("node:http"); + for (const status of [426, 404, 501]) { + const httpServer = http.createServer((_req, res) => { + res.statusCode = status; + res.end(`status ${status}`); + }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const addr = httpServer.address() as AddressInfo; + try { + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${addr.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { model: "gpt-5", input: "hi" }, + }); + expect("failed" in result).toBe(true); + if (!("failed" in result)) continue; + expect(result.cacheableAsUnsupported).toBe(true); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + } + } + }); + + it("classifies 401 / 5xx / network errors as NOT cacheable-unsupported", async () => { + const http = await import("node:http"); + for (const status of [401, 503]) { + const httpServer = http.createServer((_req, res) => { + res.statusCode = status; + res.end(`status ${status}`); + }); + await new Promise((resolve) => httpServer.listen(0, resolve)); + const addr = httpServer.address() as AddressInfo; + try { + const result = await tryResponsesWebsocketUpstream({ + provider: codexProvider(), + upstreamUrl: `http://127.0.0.1:${addr.port}/v1/responses`, + upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), + body: { model: "gpt-5", input: "hi" }, + }); + expect("failed" in result).toBe(true); + if (!("failed" in result)) continue; + expect(result.cacheableAsUnsupported).toBe(false); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + } + } + }); + it("emits an error frame when upstream WS fails mid-stream after the first event", async () => { server = await startMockServer((socket) => { socket.on("message", () => { diff --git a/src/app/v1/_lib/responses-ws/eligibility.ts b/src/app/v1/_lib/responses-ws/eligibility.ts index ae4c0e9c4..a14a9ad6b 100644 --- a/src/app/v1/_lib/responses-ws/eligibility.ts +++ b/src/app/v1/_lib/responses-ws/eligibility.ts @@ -15,6 +15,7 @@ import { isOpenaiResponsesWebsocketEnabled } from "@/lib/config/system-settings-cache"; import type { Provider } from "@/types/provider"; +import { verifyInternalRequest } from "./internal-secret"; import { isResponsesWsUnsupported } from "./unsupported-cache"; export const CLIENT_TRANSPORT_HEADER = "x-cch-client-transport"; @@ -32,6 +33,18 @@ export interface ResponsesWsEligibility { endpointId?: number | null; } +/** + * Treat the request as a WebSocket-tunneled request only when: + * 1. it carries `x-cch-client-transport: websocket`, AND + * 2. it carries a valid per-process internal loopback secret AND the + * forward flag (see internal-secret.ts). + * + * Condition (2) is what defends against an external client crafting an HTTP + * request with the public marker header to trick the forwarder into + * attempting an upstream WebSocket dial. server.js sets the secret on every + * internal tunnel request and strips inbound `x-cch-*` headers from clients, + * so external requests cannot pass this check. + */ export function isWebsocketClientRequest(headers: Headers | Record): boolean { let value: string | null | undefined; if (headers instanceof Headers) { @@ -46,7 +59,8 @@ export function isWebsocketClientRequest(headers: Headers | Record HTTP loopback tunnel. + * + * Why this exists: + * - server.js accepts client WebSocket upgrades on /v1/responses and tunnels + * each frame as an internal HTTP POST against the same listener. To let the + * forwarder know the request originated from a real WS client (so it + * should attempt an upstream WebSocket dial), we attach internal marker + * headers (x-cch-client-transport: websocket, x-cch-responses-ws-forward: + * 1). + * - Those headers travel over plain HTTP and would also be settable by an + * external attacker simply curl'ing /v1/responses with the right header + * names. Without an authentication step, that lets any HTTP client trick + * the forwarder into attempting an upstream WS dial. + * + * How this fixes it: + * - At startup the custom server populates `CCH_RESPONSES_WS_INTERNAL_SECRET` + * in `process.env` with a single, random per-process secret (or a value + * provided by the operator). The custom server adds that secret as the + * `x-cch-internal-secret` header on every internal tunnel request. + * - The eligibility check in the forwarder cross-checks the secret against + * the live process value. Internal requests pass; external requests fail + * even if they spoof the public marker headers. + * - server.js ALSO strips any inbound `x-cch-*` headers from the client WS + * handshake before tunneling, so this secret is never echoed back to a + * third party. That is defense-in-depth on top of the secret check. + * + * The secret never leaves this Node process and is never logged. + */ + +import { randomUUID } from "node:crypto"; + +export const INTERNAL_SECRET_HEADER = "x-cch-internal-secret"; +export const WS_FORWARD_FLAG_HEADER = "x-cch-responses-ws-forward"; +const ENV_VAR = "CCH_RESPONSES_WS_INTERNAL_SECRET"; + +/** + * Reserved internal markers. Headers with these names are stripped from any + * inbound client request at the WS edge, so an attacker cannot inject them + * even alongside other valid markers. + */ +export const RESERVED_INTERNAL_HEADERS = [ + "x-cch-client-transport", + WS_FORWARD_FLAG_HEADER, + INTERNAL_SECRET_HEADER, +]; + +/** + * Read the live per-process secret. Returns null when not initialized — this + * is the safe default and means `verifyInternalRequest` will reject any + * caller, including the local tunnel, until the secret is available. + */ +export function getInternalSecret(): string | null { + const value = process.env[ENV_VAR]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Initialize the per-process secret. Called once at server startup. Returns + * the resolved secret. If the operator preset `CCH_RESPONSES_WS_INTERNAL_SECRET` + * we honor that; otherwise a UUIDv4 is generated. + * + * Idempotent — repeated calls return the existing value so test harnesses + * can safely re-run setup. + */ +export function ensureInternalSecret(): string { + const existing = getInternalSecret(); + if (existing) return existing; + const generated = randomUUID(); + process.env[ENV_VAR] = generated; + return generated; +} + +/** + * Constant-time secret compare to avoid trivial timing oracles. The secret + * is short (UUID), so this is just hygiene; the real protection is that the + * value is never sent off-process. + */ +function safeEquals(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i += 1) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +function readHeader(headers: Headers | Record, name: string): string | undefined { + const lower = name.toLowerCase(); + if (headers instanceof Headers) { + return headers.get(lower) ?? undefined; + } + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === lower) return v; + } + return undefined; +} + +/** + * Returns true iff the request carries the live per-process internal secret + * AND the WS-forward flag. Any external client trying to mark itself as a + * WS-tunnel request without knowing the secret is rejected here. + */ +export function verifyInternalRequest(headers: Headers | Record): boolean { + const secret = getInternalSecret(); + if (!secret) return false; + const provided = readHeader(headers, INTERNAL_SECRET_HEADER); + if (typeof provided !== "string" || provided.length === 0) return false; + if (!safeEquals(secret, provided)) return false; + // Belt-and-braces: even with a valid secret, the call must include the + // explicit forward flag. Misses here would only happen if server.js + // forgets to set it — surfacing that as a hard fail prevents accidental + // bypass via shared secret leakage to other internal callers. + const forwardFlag = readHeader(headers, WS_FORWARD_FLAG_HEADER); + return forwardFlag === "1"; +} diff --git a/src/app/v1/_lib/responses-ws/upstream-adapter.ts b/src/app/v1/_lib/responses-ws/upstream-adapter.ts index a394d0bc1..ef51e5b2b 100644 --- a/src/app/v1/_lib/responses-ws/upstream-adapter.ts +++ b/src/app/v1/_lib/responses-ws/upstream-adapter.ts @@ -36,6 +36,15 @@ export interface UpstreamWsFailure { failed: true; reason: UpstreamWsFallbackReason; message?: string; + /** + * True only for failures that prove the endpoint does not speak the + * Responses WebSocket protocol (e.g. HTTP 4xx / 501 on the upgrade). + * Used by the caller to decide whether to cache the endpoint as + * WS-unsupported. Network-level failures (ECONNREFUSED, ETIMEDOUT, + * silent upstream, mid-handshake aborts) are NOT cacheable — they may + * recover on the next request. + */ + cacheableAsUnsupported: boolean; } export type UpstreamWsResult = UpstreamWsOutcome | UpstreamWsFailure; @@ -54,6 +63,18 @@ const HANDSHAKE_TIMEOUT_MS = 10_000; // `await openPromise` below would hang forever and tie up the request slot. const FIRST_EVENT_TIMEOUT_MS = 20_000; +// Hard limit on bytes we will buffer between the upstream WebSocket and the +// downstream SSE consumer. If the consumer stalls (or upstream sends faster +// than the SSE reader drains) we cap memory growth and abort the WS rather +// than spilling into the heap unboundedly. +const MAX_BUFFERED_QUEUE_BYTES = 8 * 1024 * 1024; // 8 MiB + +// HTTP statuses on the upgrade handshake that we treat as a definitive +// "this endpoint does not speak WebSocket" signal and cache as unsupported. +// 401 / 403 are NOT in this list because they reflect auth state, not +// protocol support. +const PROTOCOL_UNSUPPORTED_HTTP_STATUSES = new Set([400, 404, 405, 426, 501]); + // Hop-by-hop and request-shape headers that must NOT be forwarded into the // outbound WebSocket upgrade. The `ws` package handles Connection / // Upgrade / Sec-WebSocket-* itself; the body-shape headers belong to HTTP @@ -122,7 +143,7 @@ export async function tryResponsesWebsocketUpstream(options: { | (typeof WebSocketType & { new (url: string, opts?: unknown): WebSocketType }) | null; if (!WsCtor) { - return { failed: true, reason: "ws_module_unavailable" }; + return { failed: true, reason: "ws_module_unavailable", cacheableAsUnsupported: false }; } const wssUrl = toWsUrl(options.upstreamUrl); @@ -144,23 +165,29 @@ export async function tryResponsesWebsocketUpstream(options: { failed: true, reason: "ws_upgrade_rejected", message: String(err && (err as Error).message ? (err as Error).message : err), + // Constructor throws are typically URL parsing / TLS configuration — + // not a server-side protocol negative signal — so don't cache. + cacheableAsUnsupported: false, }; } + type OpenResult = + | { ok: true } + | { + ok: false; + reason: UpstreamWsFallbackReason; + message?: string; + cacheableAsUnsupported: boolean; + }; + let firstEventSeen = false; let openResolved = false; - let openPromiseResolve: ( - v: { ok: true } | { ok: false; reason: UpstreamWsFallbackReason; message?: string } - ) => void; - const openPromise = new Promise< - { ok: true } | { ok: false; reason: UpstreamWsFallbackReason; message?: string } - >((resolve) => { + let openPromiseResolve: (v: OpenResult) => void; + const openPromise = new Promise((resolve) => { openPromiseResolve = resolve; }); - const finishOpen = ( - result: { ok: true } | { ok: false; reason: UpstreamWsFallbackReason; message?: string } - ) => { + const finishOpen = (result: OpenResult) => { if (openResolved) return; openResolved = true; openPromiseResolve(result); @@ -174,6 +201,8 @@ export async function tryResponsesWebsocketUpstream(options: { ok: false, reason: "ws_error_pre_first_event", message: String(err && (err as Error).message ? (err as Error).message : err), + // Local send failure (closed underlying socket, etc.) is transient. + cacheableAsUnsupported: false, }); try { ws.close(1011); @@ -186,10 +215,16 @@ export async function tryResponsesWebsocketUpstream(options: { ws.on( "unexpected-response", (_req: unknown, res: { statusCode?: number; statusMessage?: string }) => { + const status = typeof res.statusCode === "number" ? res.statusCode : undefined; + const cacheable = + typeof status === "number" && PROTOCOL_UNSUPPORTED_HTTP_STATUSES.has(status); finishOpen({ ok: false, reason: "ws_upgrade_rejected", - message: `HTTP ${res.statusCode ?? "?"} ${res.statusMessage ?? ""}`.trim(), + message: `HTTP ${status ?? "?"} ${res.statusMessage ?? ""}`.trim(), + // Only definitive protocol negatives (4xx/501 on the upgrade path) + // are cacheable. 401/403/5xx/etc. are auth or transient state. + cacheableAsUnsupported: cacheable, }); try { ws.close(1011); @@ -202,6 +237,7 @@ export async function tryResponsesWebsocketUpstream(options: { const messageQueue: string[] = []; let queueResolver: ((value: string | null) => void) | null = null; let closed = false; + let queuedBytes = 0; // Marks an upstream failure observed AFTER the first event was emitted. // The downstream pipeline must see this as an error rather than a clean // end-of-stream so it doesn't treat a half-streamed response as success. @@ -210,6 +246,7 @@ export async function tryResponsesWebsocketUpstream(options: { ws.on("message", (data: Buffer | string) => { const text = typeof data === "string" ? data : data.toString("utf8"); + const size = Buffer.byteLength(text, "utf8"); if (!firstEventSeen) { firstEventSeen = true; if (firstEventTimer) { @@ -222,9 +259,29 @@ export async function tryResponsesWebsocketUpstream(options: { const resolve = queueResolver; queueResolver = null; resolve(text); - } else { - messageQueue.push(text); + return; } + // Hard cap on buffered bytes so a stalled SSE consumer cannot let us + // accumulate unbounded heap growth. + if (queuedBytes + size > MAX_BUFFERED_QUEUE_BYTES) { + logger.warn("[ResponsesWsAdapter] upstream queue overflow, terminating WS", { + queuedBytes, + attemptedSize: size, + }); + midStreamError = { + code: "upstream_ws_queue_overflow", + message: `buffered upstream payload exceeded ${MAX_BUFFERED_QUEUE_BYTES} bytes`, + }; + closed = true; + try { + ws.close(1011); + } catch { + // ignore + } + return; + } + messageQueue.push(text); + queuedBytes += size; }); ws.on("error", (err: Error) => { @@ -237,6 +294,9 @@ export async function tryResponsesWebsocketUpstream(options: { ok: false, reason: "ws_error_pre_first_event", message: String(err?.message ? err.message : err), + // Network errors (ECONNREFUSED, ETIMEDOUT, ECONNRESET, TLS) are + // transient — never cache them as endpoint-unsupported. + cacheableAsUnsupported: false, }); } else { midStreamError = { @@ -258,6 +318,11 @@ export async function tryResponsesWebsocketUpstream(options: { finishOpen({ ok: false, reason: "ws_closed_before_first_event", + // Endpoint upgraded successfully but closed without a frame. That + // could be transient (server restart, reload) or it could be a + // half-broken WS implementation. Conservative default: don't cache, + // re-probe on the next request. + cacheableAsUnsupported: false, }); } if (queueResolver) { @@ -290,6 +355,9 @@ export async function tryResponsesWebsocketUpstream(options: { ok: false, reason: "ws_error_pre_first_event", message: "timeout_waiting_for_first_event", + // A silent upstream is most likely transient (load, latency); the + // next request should re-probe rather than skip the WS path. + cacheableAsUnsupported: false, }); try { ws.close(1011); @@ -309,7 +377,12 @@ export async function tryResponsesWebsocketUpstream(options: { } catch { // ignore } - return { failed: true, reason: openResult.reason, message: openResult.message }; + return { + failed: true, + reason: openResult.reason, + message: openResult.message, + cacheableAsUnsupported: openResult.cacheableAsUnsupported, + }; } // Upstream WS is open and at least one event was received. Build an SSE @@ -338,9 +411,18 @@ export async function tryResponsesWebsocketUpstream(options: { return false; }; + const popMessage = (): string | undefined => { + const msg = messageQueue.shift(); + if (msg !== undefined) { + queuedBytes -= Buffer.byteLength(msg, "utf8"); + if (queuedBytes < 0) queuedBytes = 0; + } + return msg; + }; + // Drain queued first-event(s) while (messageQueue.length > 0) { - const msg = messageQueue.shift(); + const msg = popMessage(); if (msg === undefined) break; if (processText(msg)) { controller.close(); @@ -356,7 +438,7 @@ export async function tryResponsesWebsocketUpstream(options: { while (!closed) { const next = await new Promise((resolve) => { if (messageQueue.length > 0) { - resolve(messageQueue.shift() ?? null); + resolve(popMessage() ?? null); return; } queueResolver = resolve; @@ -376,7 +458,7 @@ export async function tryResponsesWebsocketUpstream(options: { // Drain any messages enqueued after the loop's last `await` resolved // with `null` (race between shift() and `closed` becoming true). while (messageQueue.length > 0) { - const msg = messageQueue.shift(); + const msg = popMessage(); if (msg === undefined) break; if (processText(msg)) { controller.close(); From ad0a9db6b66ad3cdb11bf0bdbf9193511a6f0385 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 28 Apr 2026 16:24:18 +0800 Subject: [PATCH 04/47] feat(proxy): add fake streaming whitelist Adds a system-configured fake-streaming whitelist that keeps long synchronous generation requests (image / video) alive past Cloudflare's 120s no-body timeout. For whitelisted requests the proxy returns text/event-stream immediately, sends an SSE comment heartbeat (`: ping\n\n`) every 5 seconds while it serially calls upstream providers, validates the buffered upstream response is non-empty, and only then emits a final protocol-compatible stream (or a protocol-compatible error event on terminal failure). Non-stream requests follow the same buffer + validate semantics without heartbeat. Behavior: - Default whitelist (auto-enabled when system_settings has no persisted value): gpt-image-2, gpt-image-1.5, gemini-3.1-flash-image-preview, gemini-3-pro-image-preview. - Persisted empty array is preserved as explicit opt-out. - Provider-group restriction supported per model entry; empty groupTags = all groups. - Reuses ProxyForwarder's existing multi-provider loop and fake-200 detection for fallback; the validator catches additional cases (empty content array, comment-only SSE, etc.) and returns 502 if the response is undeliverable. - Client abort cleans up heartbeat timer and stops further attempts; abort is not counted as a provider failure. Includes: - Drizzle migration 0099 adds `fake_streaming_whitelist` JSONB column. - Eligibility matcher, stream intent / non-stream clone helpers, response validator, and protocol emitters for Anthropic / OpenAI Chat / OpenAI Responses / Gemini. - Settings UI section with model + group multi-select, all-groups hint, and i18n in zh-CN / zh-TW / en / ja / ru. - 121 unit tests covering settings persistence, UI, eligibility matching, stream-intent detection, response validation, protocol emission, the orchestrator (no-race + abort cleanup), and the runner (heartbeat + final emission + non-stream JSON path). Co-Authored-By: Claude Opus 4.7 (1M context) --- drizzle/0099_equal_expediter.sql | 1 + drizzle/meta/0099_snapshot.json | 4477 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- messages/en/settings/config.json | 11 + messages/ja/settings/config.json | 11 + messages/ru/settings/config.json | 11 + messages/zh-CN/settings/config.json | 11 + messages/zh-TW/settings/config.json | 11 + src/actions/system-config.ts | 3 + .../_components/system-settings-form.tsx | 143 + src/app/[locale]/settings/config/page.tsx | 1 + src/app/v1/_lib/proxy-handler.ts | 18 + .../_lib/proxy/fake-streaming/eligibility.ts | 41 + .../v1/_lib/proxy/fake-streaming/emitters.ts | 352 ++ .../_lib/proxy/fake-streaming/orchestrator.ts | 153 + .../proxy/fake-streaming/proxy-integration.ts | 144 + .../fake-streaming/response-validator.ts | 373 ++ .../v1/_lib/proxy/fake-streaming/runner.ts | 225 + .../proxy/fake-streaming/stream-intent.ts | 110 + src/drizzle/schema.ts | 6 + src/lib/config/system-settings-cache.ts | 5 + src/lib/validation/schemas.ts | 37 + src/repository/_shared/transformers.ts | 46 +- src/repository/system-config.ts | 178 +- src/types/system-config.ts | 22 + ...stem-config-fake-streaming-setting.test.ts | 341 ++ .../proxy/fake-streaming-eligibility.test.ts | 109 + .../proxy/fake-streaming-orchestrator.test.ts | 246 + .../fake-streaming-response-validator.test.ts | 291 ++ .../proxy/fake-streaming-response.test.ts | 287 ++ .../fake-streaming-stream-intent.test.ts | 242 + .../response-handler-fake-streaming.test.ts | 260 + ...stem-settings-form-fake-streaming.test.tsx | 292 ++ ...ystem-settings-form-ip-extraction.test.tsx | 1 + ...m-settings-form-non-chat-fallback.test.tsx | 2 + 35 files changed, 8413 insertions(+), 57 deletions(-) create mode 100644 drizzle/0099_equal_expediter.sql create mode 100644 drizzle/meta/0099_snapshot.json create mode 100644 src/app/v1/_lib/proxy/fake-streaming/eligibility.ts create mode 100644 src/app/v1/_lib/proxy/fake-streaming/emitters.ts create mode 100644 src/app/v1/_lib/proxy/fake-streaming/orchestrator.ts create mode 100644 src/app/v1/_lib/proxy/fake-streaming/proxy-integration.ts create mode 100644 src/app/v1/_lib/proxy/fake-streaming/response-validator.ts create mode 100644 src/app/v1/_lib/proxy/fake-streaming/runner.ts create mode 100644 src/app/v1/_lib/proxy/fake-streaming/stream-intent.ts create mode 100644 tests/unit/actions/system-config-fake-streaming-setting.test.ts create mode 100644 tests/unit/proxy/fake-streaming-eligibility.test.ts create mode 100644 tests/unit/proxy/fake-streaming-orchestrator.test.ts create mode 100644 tests/unit/proxy/fake-streaming-response-validator.test.ts create mode 100644 tests/unit/proxy/fake-streaming-response.test.ts create mode 100644 tests/unit/proxy/fake-streaming-stream-intent.test.ts create mode 100644 tests/unit/proxy/response-handler-fake-streaming.test.ts create mode 100644 tests/unit/settings/system-settings-form-fake-streaming.test.tsx diff --git a/drizzle/0099_equal_expediter.sql b/drizzle/0099_equal_expediter.sql new file mode 100644 index 000000000..f3d55d810 --- /dev/null +++ b/drizzle/0099_equal_expediter.sql @@ -0,0 +1 @@ +ALTER TABLE "system_settings" ADD COLUMN "fake_streaming_whitelist" jsonb; \ No newline at end of file diff --git a/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json new file mode 100644 index 000000000..40084354d --- /dev/null +++ b/drizzle/meta/0099_snapshot.json @@ -0,0 +1,4477 @@ +{ + "id": "0121f242-24bd-4fb5-b4c4-1889677099c5", + "prevId": "6014bb32-638d-4ca1-bb4b-16d9f3fe0e01", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "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 + }, + "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 + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "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\".\"status_code\" IS NOT NULL", + "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": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "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\".\"client_ip\" IS NOT 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_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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(255)", + "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 + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "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": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "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'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "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 + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "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_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": 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 + }, + "group_cost_multiplier": { + "name": "group_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_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 + }, + "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 579a42619..e2d576280 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1776965161943, "tag": "0098_equal_selene", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1777362451734, + "tag": "0099_equal_expediter", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 0a102133b..14a4cfef5 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -70,6 +70,17 @@ "enableResponseInputRectifierDesc": "Automatically normalizes non-array input (string shortcut or single message object) in /v1/responses requests to standard array format before processing (enabled by default).", "allowNonConversationEndpointProviderFallback": "Allow cross-provider fallback for non-conversation endpoints", "allowNonConversationEndpointProviderFallbackDesc": "Controls whether /v1/messages/count_tokens and /v1/responses/compact may reuse the existing decision chain to switch to a compatible provider after the current provider fails. Enabled by default while preserving raw passthrough and non-billing semantics.", + "fakeStreaming": { + "title": "Fake streaming whitelist", + "description": "For long-running synchronous generations (image / video) that exceed Cloudflare's 120s no-body timeout, CCH keeps the SSE connection alive with safe heartbeat pings while it serially calls upstream providers internally and only emits a final, non-empty result. Listed models are auto-enabled; an empty list disables fake streaming entirely.", + "modelLabel": "Model", + "modelPlaceholder": "exact client-requested model name", + "groupsLabel": "Groups", + "allGroupsHint": "No groups selected — applies to all provider groups.", + "addModel": "Add model", + "remove": "Remove", + "emptyState": "No models configured. Fake streaming is disabled — use \"Add model\" to enable it for specific models." + }, "enableCodexSessionIdCompletion": "Enable Codex Session ID Completion", "enableCodexSessionIdCompletionDesc": "When Codex requests provide only one of session_id (header) or prompt_cache_key (body), automatically completes the other. If both are missing, generates a UUID v7 session id and reuses it stably within the same conversation.", "enableClaudeMetadataUserIdInjection": "Enable Claude metadata.user_id Injection", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index bc47d2580..0e8e99190 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -70,6 +70,17 @@ "enableResponseInputRectifierDesc": "/v1/responses リクエストの非配列 input(文字列ショートカットまたは role/type 付き単一メッセージオブジェクト)を処理前に標準の配列形式に自動正規化します(既定で有効)。", "allowNonConversationEndpointProviderFallback": "非会話エンドポイントのクロスプロバイダ fallback を許可", "allowNonConversationEndpointProviderFallbackDesc": "/v1/messages/count_tokens と /v1/responses/compact で現在のプロバイダが失敗した際に、既存の決定チェーンを使って互換プロバイダへ切り替えて再試行するかを制御します。既定で有効で、raw passthrough と非課金の意味論は維持されます。", + "fakeStreaming": { + "title": "Fake ストリーミング出力ホワイトリスト", + "description": "画像 / 動画など Cloudflare の 120 秒ノーボディタイムアウトを超えやすい長時間同期生成では、CCH が SSE ハートビートで接続を維持しつつ内部で上流プロバイダを直列に呼び出し、最終的な非空結果のみをクライアントへ返します。リスト掲載モデルは自動的に有効化、空リストは完全に無効化を意味します。", + "modelLabel": "モデル", + "modelPlaceholder": "クライアント要求の正確なモデル名", + "groupsLabel": "プロバイダグループ", + "allGroupsHint": "グループ未選択 → すべてのプロバイダグループに適用されます。", + "addModel": "モデル追加", + "remove": "削除", + "emptyState": "モデル未設定 — fake streaming は無効です。「モデル追加」で対象モデルを設定すると有効化されます。" + }, "enableCodexSessionIdCompletion": "Codex セッションID補完を有効化", "enableCodexSessionIdCompletionDesc": "Codex リクエストで session_id(ヘッダー)または prompt_cache_key(ボディ)のどちらか一方しか提供されない場合に、欠けている方を自動補完します。両方ない場合は UUID v7 のセッションIDを生成し、同一対話内で安定して再利用します。", "enableClaudeMetadataUserIdInjection": "Claude metadata.user_id 注入を有効化", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index d6920235a..ca2ff1bac 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -70,6 +70,17 @@ "enableResponseInputRectifierDesc": "Автоматически нормализует не-массивные input (строковые сокращения или одиночные объекты сообщений с role/type) в запросах /v1/responses в стандартный формат массива перед обработкой (включено по умолчанию).", "allowNonConversationEndpointProviderFallback": "Разрешить cross-provider fallback для не-диалоговых endpoint", "allowNonConversationEndpointProviderFallbackDesc": "Управляет тем, могут ли /v1/messages/count_tokens и /v1/responses/compact при ошибке текущего провайдера использовать существующую decision chain для переключения на совместимый провайдер. По умолчанию включено и сохраняет raw passthrough и non-billing семантику.", + "fakeStreaming": { + "title": "Список «фейковой» потоковой выдачи", + "description": "Для длительных синхронных генераций (изображения / видео), которые могут превысить 120-секундный no-body таймаут Cloudflare, CCH удерживает SSE-соединение безопасными heartbeat-пингами, последовательно опрашивает upstream-провайдеров во внутреннем цикле и отправляет клиенту только финальный непустой результат. Модели в списке включаются автоматически; пустой список полностью отключает функцию.", + "modelLabel": "Модель", + "modelPlaceholder": "точное имя модели из запроса клиента", + "groupsLabel": "Группы провайдеров", + "allGroupsHint": "Группы не выбраны — применяется ко всем группам провайдеров.", + "addModel": "Добавить модель", + "remove": "Удалить", + "emptyState": "Модели не настроены — fake streaming отключён. Нажмите «Добавить модель», чтобы включить его для конкретных моделей." + }, "enableCodexSessionIdCompletion": "Включить дополнение Session ID для Codex", "enableCodexSessionIdCompletionDesc": "Если в Codex-запросе присутствует только session_id (в заголовках) или prompt_cache_key (в теле), автоматически дополняет отсутствующее поле. Если оба отсутствуют, генерирует UUID v7 и стабильно переиспользует его в рамках одного диалога.", "enableClaudeMetadataUserIdInjection": "Включить инъекцию Claude metadata.user_id", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 8b95c5bdf..6acca79c0 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -62,6 +62,17 @@ "enableResponseInputRectifierDesc": "自动将 /v1/responses 请求中的非数组 input(字符串简写或带 role/type 的单消息对象)规范化为标准数组格式后再处理(默认开启)。", "allowNonConversationEndpointProviderFallback": "允许非对话端点跨供应商 fallback", "allowNonConversationEndpointProviderFallbackDesc": "控制 /v1/messages/count_tokens 与 /v1/responses/compact 在当前供应商失败时,是否沿用现有决策链切换到兼容供应商重试。默认开启,并继续保持 raw passthrough 与非计费语义。", + "fakeStreaming": { + "title": "Fake 流式输出白名单", + "description": "针对图像 / 视频等长耗时同步生成场景(容易超过 Cloudflare 120 秒无响应体超时),CCH 会先返回 SSE 心跳保活,并在内部串行调用上游供应商,仅在最终拿到非空结果时回写最终响应。命中白名单的模型会启用 fake streaming;列表为空表示完全禁用。", + "modelLabel": "模型", + "modelPlaceholder": "客户端请求中的精确模型名", + "groupsLabel": "供应商分组", + "allGroupsHint": "未选择分组 → 适用于所有供应商分组。", + "addModel": "添加模型", + "remove": "删除", + "emptyState": "尚未配置任何模型,fake streaming 处于关闭状态。点击 \"添加模型\" 即可为指定模型启用。" + }, "enableCodexSessionIdCompletion": "启用 Codex Session ID 补全", "enableCodexSessionIdCompletionDesc": "当 Codex 请求仅提供 session_id(请求头)或 prompt_cache_key(请求体)之一时,自动补全另一个;若两者均缺失,则生成 UUID v7 会话 ID,并在同一对话内稳定复用。", "enableClaudeMetadataUserIdInjection": "启用 Claude metadata.user_id 注入", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index cfeb43fd2..aa90e180b 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -70,6 +70,17 @@ "enableResponseInputRectifierDesc": "自動將 /v1/responses 請求中的非陣列 input(字串簡寫或帶 role/type 的單訊息物件)規範化為標準陣列格式後再處理(預設開啟)。", "allowNonConversationEndpointProviderFallback": "允許非對話端點跨供應商 fallback", "allowNonConversationEndpointProviderFallbackDesc": "控制 /v1/messages/count_tokens 與 /v1/responses/compact 在目前供應商失敗時,是否沿用既有決策鏈切換到相容供應商重試。預設開啟,並持續保持 raw passthrough 與非計費語義。", + "fakeStreaming": { + "title": "Fake 串流輸出白名單", + "description": "針對圖像 / 影片等長耗時同步生成場景(容易超過 Cloudflare 120 秒無回應主體逾時),CCH 會先回傳 SSE 心跳保持連線,在內部串行呼叫上游供應商,僅在最終取得非空結果時才回寫最終回應。命中白名單的模型會啟用 fake streaming;列表為空表示完全停用。", + "modelLabel": "模型", + "modelPlaceholder": "客戶端請求中的精確模型名稱", + "groupsLabel": "供應商分組", + "allGroupsHint": "未選擇分組 → 套用於所有供應商分組。", + "addModel": "新增模型", + "remove": "刪除", + "emptyState": "尚未設定任何模型,fake streaming 已關閉。點擊「新增模型」即可為指定模型啟用。" + }, "enableCodexSessionIdCompletion": "啟用 Codex Session ID 補全", "enableCodexSessionIdCompletionDesc": "當 Codex 請求僅提供 session_id(請求頭)或 prompt_cache_key(請求體)之一時,自動補全另一個;若兩者皆缺失,則產生 UUID v7 會話 ID,並在同一對話內穩定複用。", "enableClaudeMetadataUserIdInjection": "啟用 Claude metadata.user_id 注入", diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 65f711843..efc39f4ff 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -14,6 +14,7 @@ import { getSystemSettings, updateSystemSettings } from "@/repository/system-con import type { IpExtractionConfig } from "@/types/ip-extraction"; import type { CodexPriorityBillingSource, + FakeStreamingWhitelistEntry, ResponseFixerConfig, SystemSettings, } from "@/types/system-config"; @@ -72,6 +73,7 @@ export async function saveSystemSettings(formData: { enableBillingHeaderRectifier?: boolean; enableResponseInputRectifier?: boolean; allowNonConversationEndpointProviderFallback?: boolean; + fakeStreamingWhitelist?: FakeStreamingWhitelistEntry[]; enableCodexSessionIdCompletion?: boolean; enableClaudeMetadataUserIdInjection?: boolean; enableResponseFixer?: boolean; @@ -121,6 +123,7 @@ export async function saveSystemSettings(formData: { enableResponseInputRectifier: validated.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: validated.allowNonConversationEndpointProviderFallback, + fakeStreamingWhitelist: validated.fakeStreamingWhitelist, enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection, enableResponseFixer: validated.enableResponseFixer, 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 c60180fff..68b255f44 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -11,8 +11,11 @@ import { MapPin, Network, Pencil, + Plus, + Radio, Terminal, Thermometer, + Trash2, Wrench, Zap, } from "lucide-react"; @@ -21,6 +24,7 @@ import { useTranslations } from "next-intl"; import { useState, useTransition } from "react"; import { toast } from "sonner"; import { saveSystemSettings } from "@/actions/system-config"; +import { GroupMultiSelect } from "@/app/[locale]/settings/request-filters/_components/group-multi-select"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { InlineWarning } from "@/components/ui/inline-warning"; @@ -49,6 +53,7 @@ import { DEFAULT_IP_EXTRACTION_CONFIG, type IpExtractionConfig } from "@/types/i import type { BillingModelSource, CodexPriorityBillingSource, + FakeStreamingWhitelistEntry, SystemSettings, } from "@/types/system-config"; @@ -71,6 +76,7 @@ interface SystemSettingsFormProps { | "enableResponseInputRectifier" | "enableThinkingBudgetRectifier" | "allowNonConversationEndpointProviderFallback" + | "fakeStreamingWhitelist" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" @@ -144,6 +150,14 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) allowNonConversationEndpointProviderFallback, setAllowNonConversationEndpointProviderFallback, ] = useState(initialSettings.allowNonConversationEndpointProviderFallback); + const [fakeStreamingWhitelist, setFakeStreamingWhitelist] = useState< + FakeStreamingWhitelistEntry[] + >(() => + (initialSettings.fakeStreamingWhitelist ?? []).map((entry) => ({ + model: entry.model, + groupTags: [...entry.groupTags], + })) + ); const [enableThinkingBudgetRectifier, setEnableThinkingBudgetRectifier] = useState( initialSettings.enableThinkingBudgetRectifier ); @@ -233,6 +247,25 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) ipExtractionConfigToSave = parsed as IpExtractionConfig; } + const sanitizedFakeStreamingWhitelist: FakeStreamingWhitelistEntry[] = []; + { + const seenModels = new Set(); + for (const entry of fakeStreamingWhitelist) { + const model = entry.model.trim(); + if (!model || seenModels.has(model)) continue; + seenModels.add(model); + const groupSeen = new Set(); + const groupTags: string[] = []; + for (const tag of entry.groupTags) { + const trimmed = tag.trim(); + if (!trimmed || groupSeen.has(trimmed)) continue; + groupSeen.add(trimmed); + groupTags.push(trimmed); + } + sanitizedFakeStreamingWhitelist.push({ model, groupTags }); + } + } + startTransition(async () => { const result = await saveSystemSettings({ siteTitle, @@ -250,6 +283,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) enableBillingHeaderRectifier, enableResponseInputRectifier, allowNonConversationEndpointProviderFallback, + fakeStreamingWhitelist: sanitizedFakeStreamingWhitelist, enableThinkingBudgetRectifier, enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection, @@ -288,6 +322,12 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setAllowNonConversationEndpointProviderFallback( result.data.allowNonConversationEndpointProviderFallback ); + setFakeStreamingWhitelist( + (result.data.fakeStreamingWhitelist ?? []).map((entry) => ({ + model: entry.model, + groupTags: [...entry.groupTags], + })) + ); setEnableThinkingBudgetRectifier(result.data.enableThinkingBudgetRectifier); setEnableCodexSessionIdCompletion(result.data.enableCodexSessionIdCompletion); setEnableClaudeMetadataUserIdInjection(result.data.enableClaudeMetadataUserIdInjection); @@ -714,6 +754,109 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) />
+ {/* Fake Streaming Whitelist */} +
+
+
+ +
+
+

{t("fakeStreaming.title")}

+

+ {t("fakeStreaming.description")} +

+
+
+ + {fakeStreamingWhitelist.length === 0 ? ( +

+ {t("fakeStreaming.emptyState")} +

+ ) : ( +
+ {fakeStreamingWhitelist.map((entry, index) => ( +
+
+ + { + const next = event.target.value; + setFakeStreamingWhitelist((prev) => + prev.map((item, i) => (i === index ? { ...item, model: next } : item)) + ); + }} + placeholder={t("fakeStreaming.modelPlaceholder")} + disabled={isPending} + className={inputClassName} + /> + +
+
+ +
+ { + setFakeStreamingWhitelist((prev) => + prev.map((item, i) => (i === index ? { ...item, groupTags } : item)) + ); + }} + disabled={isPending} + /> + {entry.groupTags.length === 0 ? ( +

+ {t("fakeStreaming.allGroupsHint")} +

+ ) : null} +
+
+
+ ))} +
+ )} + + +
+ {/* Enable Codex Session ID Completion */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index d139c43eb..ad51d71a4 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -62,6 +62,7 @@ async function SettingsConfigContent({ locale }: { locale: string }) { enableResponseInputRectifier: settings.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: settings.allowNonConversationEndpointProviderFallback, + fakeStreamingWhitelist: settings.fakeStreamingWhitelist, enableCodexSessionIdCompletion: settings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: settings.enableClaudeMetadataUserIdInjection, enableResponseFixer: settings.enableResponseFixer, diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index c161da211..ede204dac 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -6,6 +6,7 @@ import { SessionTracker } from "@/lib/session-tracker"; import { ProxyErrorHandler } from "./proxy/error-handler"; import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; import { ProxyError } from "./proxy/errors"; +import { tryFakeStreamingPath } from "./proxy/fake-streaming/proxy-integration"; import { detectClientFormat, detectFormatByEndpoint } from "./proxy/format-mapper"; import { ProxyForwarder } from "./proxy/forwarder"; import { GuardPipelineBuilder } from "./proxy/guard-pipeline"; @@ -101,6 +102,23 @@ export async function handleProxyRequest(c: Context): Promise { } session.recordForwardStart(); + + // Fake streaming: if the client-requested model is whitelisted for the + // current provider group, hand off to the fake-streaming runner which + // keeps the SSE connection alive with heartbeats while it serially calls + // upstream and validates the buffered response before emitting it. + try { + const fakeStreamingSettings = await getCachedSystemSettings(); + const fakeStreamingResponse = await tryFakeStreamingPath(session, fakeStreamingSettings); + if (fakeStreamingResponse) { + return await attachSessionIdToErrorResponse(session.sessionId, fakeStreamingResponse); + } + } catch (fakeStreamingError) { + logger.warn("[ProxyHandler] fake streaming path threw; falling back to normal flow", { + error: fakeStreamingError, + }); + } + const response = await ProxyForwarder.send(session); const handled = await ProxyResponseHandler.dispatch(session, response); const finalResponse = await attachSessionIdToErrorResponse(session.sessionId, handled); diff --git a/src/app/v1/_lib/proxy/fake-streaming/eligibility.ts b/src/app/v1/_lib/proxy/fake-streaming/eligibility.ts new file mode 100644 index 000000000..21efafdca --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/eligibility.ts @@ -0,0 +1,41 @@ +import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; +import type { FakeStreamingWhitelistEntry } from "@/types/system-config"; + +/** + * Pure helper: decide whether a request is eligible for fake streaming + * orchestration based on the configured whitelist. + * + * Rules: + * - Trim model and group inputs. + * - Empty / whitespace-only model => false. + * - Match whitelist entries by EXACT trimmed model (no prefix / glob / regex). + * - For the matching entry, an empty groupTags array means "all provider groups". + * - For non-empty groupTags, the trimmed providerGroupTag must match one of the + * trimmed configured tags. + * - A null / undefined / empty providerGroupTag is treated as PROVIDER_GROUP.DEFAULT + * for non-empty groupTags matching, mirroring resolveProviderGroupsWithDefault. + */ +export function isFakeStreamingEligible( + clientRequestedModel: string, + providerGroupTag: string | null | undefined, + whitelist: ReadonlyArray +): boolean { + if (whitelist.length === 0) return false; + + const model = clientRequestedModel.trim(); + if (model.length === 0) return false; + + const entry = whitelist.find((candidate) => candidate.model.trim() === model); + if (!entry) return false; + + const trimmedGroups = entry.groupTags.map((tag) => tag.trim()).filter((tag) => tag.length > 0); + + if (trimmedGroups.length === 0) return true; + + const requestGroup = + typeof providerGroupTag === "string" && providerGroupTag.trim().length > 0 + ? providerGroupTag.trim() + : PROVIDER_GROUP.DEFAULT; + + return trimmedGroups.includes(requestGroup); +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/emitters.ts b/src/app/v1/_lib/proxy/fake-streaming/emitters.ts new file mode 100644 index 000000000..afb321c01 --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/emitters.ts @@ -0,0 +1,352 @@ +import type { ProtocolFamily } from "./response-validator"; + +export interface NonStreamEmitInput { + family: ProtocolFamily; + finalBody: string; +} + +export interface StreamEmitInput { + family: ProtocolFamily; + finalBody: string; +} + +export interface StreamErrorEmitInput { + family: ProtocolFamily; + errorMessage: string; + errorCode?: string; +} + +/** + * Non-stream emitter is a passthrough: the validator already guaranteed the + * body is non-empty and protocol-compatible, so the orchestrator simply forwards + * the upstream body bytes verbatim. + */ +export function emitFinalNonStream(input: NonStreamEmitInput): string { + return input.finalBody; +} + +/** + * Stream emitter. Given a fully buffered, validated upstream JSON body, return + * a protocol-compatible SSE byte string that downstream clients can decode as + * if it were a regular streaming response. + */ +export function emitFinalStream(input: StreamEmitInput): string { + const parsed = parseJsonOrThrow(input.finalBody); + switch (input.family) { + case "anthropic": + return emitAnthropicStream(parsed); + case "openai-chat": + return emitOpenAIChatStream(parsed); + case "openai-responses": + return emitOpenAIResponsesStream(parsed); + case "gemini": + return emitGeminiStream(parsed, input.finalBody); + } +} + +/** + * Stream error emitter. Used after heartbeats have already been sent and the + * orchestrator decides every upstream attempt failed. The output must be + * protocol-compatible, must NOT contain a success terminator, and is the + * caller's responsibility to flush before closing the response stream. + */ +export function emitStreamError(input: StreamErrorEmitInput): string { + const code = input.errorCode ?? "upstream_failure"; + switch (input.family) { + case "anthropic": + return formatSseEvent("error", { + type: "error", + error: { type: code, message: input.errorMessage }, + }); + case "openai-chat": + return formatSseData({ + error: { code, message: input.errorMessage }, + }); + case "openai-responses": + return formatSseEvent("response.error", { + type: "response.error", + error: { code, message: input.errorMessage }, + }); + case "gemini": + return formatSseData({ + error: { code, message: input.errorMessage, status: code }, + }); + } +} + +interface AnthropicMessage { + id?: unknown; + type?: unknown; + role?: unknown; + model?: unknown; + content?: unknown; + stop_reason?: unknown; + stop_sequence?: unknown; + usage?: unknown; +} + +function emitAnthropicStream(parsed: unknown): string { + const msg = (parsed ?? {}) as AnthropicMessage; + const blocks = Array.isArray(msg.content) ? (msg.content as unknown[]) : []; + const baseMessage = { + id: msg.id ?? "msg_fake_streaming", + type: "message", + role: msg.role ?? "assistant", + model: msg.model ?? null, + content: [] as unknown[], + stop_reason: null, + stop_sequence: null, + usage: msg.usage ?? null, + }; + + const parts: string[] = []; + parts.push(formatSseEvent("message_start", { type: "message_start", message: baseMessage })); + + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + if (!block || typeof block !== "object") continue; + const typed = block as { type?: unknown; text?: unknown }; + if (typed.type === "text" && typeof typed.text === "string") { + parts.push( + formatSseEvent("content_block_start", { + type: "content_block_start", + index: i, + content_block: { type: "text", text: "" }, + }) + ); + parts.push( + formatSseEvent("content_block_delta", { + type: "content_block_delta", + index: i, + delta: { type: "text_delta", text: typed.text }, + }) + ); + parts.push(formatSseEvent("content_block_stop", { type: "content_block_stop", index: i })); + continue; + } + // Non-text block: emit content_block_start with the full block, then stop. + parts.push( + formatSseEvent("content_block_start", { + type: "content_block_start", + index: i, + content_block: block, + }) + ); + parts.push(formatSseEvent("content_block_stop", { type: "content_block_stop", index: i })); + } + + parts.push( + formatSseEvent("message_delta", { + type: "message_delta", + delta: { + stop_reason: msg.stop_reason ?? null, + stop_sequence: msg.stop_sequence ?? null, + }, + usage: msg.usage ?? undefined, + }) + ); + parts.push(formatSseEvent("message_stop", { type: "message_stop" })); + return parts.join(""); +} + +interface ChatChoice { + index?: unknown; + message?: unknown; + finish_reason?: unknown; + logprobs?: unknown; +} + +interface ChatCompletion { + id?: unknown; + object?: unknown; + created?: unknown; + model?: unknown; + choices?: unknown; + usage?: unknown; + system_fingerprint?: unknown; +} + +function emitOpenAIChatStream(parsed: unknown): string { + const completion = (parsed ?? {}) as ChatCompletion; + const choices = Array.isArray(completion.choices) ? (completion.choices as ChatChoice[]) : []; + const baseChunk = { + id: completion.id ?? "chatcmpl_fake_streaming", + object: "chat.completion.chunk", + created: completion.created ?? Math.floor(Date.now() / 1000), + model: completion.model ?? null, + system_fingerprint: completion.system_fingerprint ?? null, + }; + + const parts: string[] = []; + + // First, emit a "role" delta chunk per choice — initialises the assistant turn. + parts.push( + formatSseData({ + ...baseChunk, + choices: choices.map((choice, index) => ({ + index: typeof choice.index === "number" ? choice.index : index, + delta: { role: messageRole(choice.message) ?? "assistant" }, + finish_reason: null, + logprobs: null, + })), + }) + ); + + // Then emit content / tool_calls deltas. We bundle each choice's full content + // into one chunk to minimise SSE noise; downstream clients accept a single + // delta the same as multiple smaller ones. + parts.push( + formatSseData({ + ...baseChunk, + choices: choices.map((choice, index) => { + const message = (choice.message ?? {}) as Record; + const delta: Record = {}; + if (typeof message.content === "string") { + delta.content = message.content; + } else if (Array.isArray(message.content)) { + delta.content = message.content; + } + if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { + delta.tool_calls = message.tool_calls; + } + if (message.function_call && typeof message.function_call === "object") { + delta.function_call = message.function_call; + } + return { + index: typeof choice.index === "number" ? choice.index : index, + delta, + finish_reason: null, + logprobs: choice.logprobs ?? null, + }; + }), + }) + ); + + // Finish reason chunk per choice. + parts.push( + formatSseData({ + ...baseChunk, + choices: choices.map((choice, index) => ({ + index: typeof choice.index === "number" ? choice.index : index, + delta: {}, + finish_reason: choice.finish_reason ?? "stop", + logprobs: null, + })), + usage: completion.usage ?? undefined, + }) + ); + + parts.push(formatRawSse("data: [DONE]\n\n")); + return parts.join(""); +} + +function messageRole(message: unknown): string | null { + if (!message || typeof message !== "object") return null; + const role = (message as { role?: unknown }).role; + return typeof role === "string" ? role : null; +} + +interface OpenAIResponsesEnvelope { + id?: unknown; + object?: unknown; + output?: unknown; +} + +function emitOpenAIResponsesStream(parsed: unknown): string { + const envelope = (parsed ?? {}) as OpenAIResponsesEnvelope; + const parts: string[] = []; + + // response.created mirrors the final response shape but with empty output for + // clients that build state incrementally. + const createdResponse = { ...((parsed as object) ?? {}), output: [] }; + parts.push( + formatSseEvent("response.created", { type: "response.created", response: createdResponse }) + ); + + const output = Array.isArray(envelope.output) ? (envelope.output as unknown[]) : []; + for (let i = 0; i < output.length; i += 1) { + const item = output[i]; + parts.push( + formatSseEvent("response.output_item.added", { + type: "response.output_item.added", + output_index: i, + item, + }) + ); + + if (item && typeof item === "object" && (item as { type?: unknown }).type === "message") { + const content = ((item as { content?: unknown }).content as unknown[]) ?? []; + for (let p = 0; p < content.length; p += 1) { + const part = content[p]; + if ( + part && + typeof part === "object" && + (part as { type?: unknown }).type === "output_text" && + typeof (part as { text?: unknown }).text === "string" + ) { + const text = (part as { text: string }).text; + parts.push( + formatSseEvent("response.output_text.delta", { + type: "response.output_text.delta", + output_index: i, + content_index: p, + delta: text, + }) + ); + parts.push( + formatSseEvent("response.output_text.done", { + type: "response.output_text.done", + output_index: i, + content_index: p, + text, + }) + ); + } + } + } + + parts.push( + formatSseEvent("response.output_item.done", { + type: "response.output_item.done", + output_index: i, + item, + }) + ); + } + + parts.push( + formatSseEvent("response.completed", { type: "response.completed", response: parsed }) + ); + return parts.join(""); +} + +function emitGeminiStream(_parsed: unknown, finalBody: string): string { + // Gemini stream framing is `data: \n\n` per response candidate + // bundle. Since the upstream validation guaranteed the body is a complete + // candidates payload, emit the whole thing as a single SSE event. + // Re-stringifying through JSON.parse/stringify would lose key ordering / + // numerical precision; just trim and emit the original bytes. + return formatRawSse(`data: ${finalBody.trim()}\n\n`); +} + +function formatSseEvent(eventName: string, payload: unknown): string { + return `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`; +} + +function formatSseData(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +function formatRawSse(text: string): string { + return text; +} + +function parseJsonOrThrow(body: string): unknown { + try { + return JSON.parse(body); + } catch (error) { + throw new Error( + `fake streaming emitter received invalid JSON body (${(error as Error).message})` + ); + } +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/orchestrator.ts b/src/app/v1/_lib/proxy/fake-streaming/orchestrator.ts new file mode 100644 index 000000000..3d0ea7a8f --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/orchestrator.ts @@ -0,0 +1,153 @@ +import { + type ProtocolFamily, + type ValidationResult, + validateUpstreamResponse, +} from "./response-validator"; + +export interface FakeStreamingAttemptOutcome { + status: number; + body: string; + providerId: string; +} + +export type AttemptPerformer = ( + attemptIndex: number, + abortSignal: AbortSignal +) => Promise; + +export interface FakeStreamingAttemptRecord { + providerId: string; + status: number; + validation: ValidationResult; +} + +export type FakeStreamingErrorCode = + | "upstream_all_attempts_failed" + | "client_abort" + | "no_providers"; + +export interface OrchestrateInput { + family: ProtocolFamily; + performAttempt: AttemptPerformer; + abortSignal: AbortSignal; + maxAttempts: number; + isStream?: boolean; // default true: orchestrator runs while client expects a stream +} + +export interface OrchestrateResult { + ok: boolean; + finalBody?: string; + finalProviderId?: string; + attempts: FakeStreamingAttemptRecord[]; + errorCode?: FakeStreamingErrorCode; + errorMessage?: string; +} + +/** + * Run upstream attempts strictly serially. Each attempt is a complete buffered + * upstream fetch (the caller is responsible for converting stream upstream into + * a buffered body). The validator decides whether the buffered body is + * deliverable; on failure, we move on to the next provider. + * + * The function exits as soon as: + * - validator returns ok (success), or + * - performAttempt returns null (no more providers / loop exhausted), or + * - the abort signal fires (client disconnected — no further fallback), or + * - maxAttempts is reached. + */ +export async function orchestrateFakeStreamingAttempts( + input: OrchestrateInput +): Promise { + const attempts: FakeStreamingAttemptRecord[] = []; + // The validator default is "stream === false" semantics, but for fake + // streaming we always buffer upstream as non-stream and rely on the + // protocol-family validation rules. Allow callers to override. + const validateAsStream = input.isStream === true ? true : false; + + for (let attemptIndex = 0; attemptIndex < input.maxAttempts; attemptIndex += 1) { + if (input.abortSignal.aborted) { + return { + ok: false, + attempts, + errorCode: "client_abort", + errorMessage: "client disconnected", + }; + } + + const attemptAbort = new AbortController(); + const onParentAbort = () => attemptAbort.abort(); + input.abortSignal.addEventListener("abort", onParentAbort, { once: true }); + + let outcome: FakeStreamingAttemptOutcome | null; + try { + outcome = await input.performAttempt(attemptIndex, attemptAbort.signal); + } catch (error) { + input.abortSignal.removeEventListener("abort", onParentAbort); + if (input.abortSignal.aborted || isAbortError(error)) { + return { + ok: false, + attempts, + errorCode: "client_abort", + errorMessage: error instanceof Error ? error.message : "client disconnected", + }; + } + // Re-throw non-abort errors so the caller can decide what to do. + throw error; + } finally { + input.abortSignal.removeEventListener("abort", onParentAbort); + } + + if (outcome === null) { + if (attempts.length === 0) { + return { + ok: false, + attempts, + errorCode: "no_providers", + errorMessage: "no providers available", + }; + } + return { + ok: false, + attempts, + errorCode: "upstream_all_attempts_failed", + errorMessage: "all upstream attempts failed and no more providers", + }; + } + + const validation = validateUpstreamResponse({ + family: input.family, + status: outcome.status, + body: outcome.body, + isStream: validateAsStream, + }); + + attempts.push({ + providerId: outcome.providerId, + status: outcome.status, + validation, + }); + + if (validation.ok) { + return { + ok: true, + finalBody: outcome.body, + finalProviderId: outcome.providerId, + attempts, + }; + } + } + + return { + ok: false, + attempts, + errorCode: "upstream_all_attempts_failed", + errorMessage: "all upstream attempts failed", + }; +} + +function isAbortError(error: unknown): boolean { + if (!error) return false; + const name = (error as { name?: unknown }).name; + if (typeof name === "string" && name === "AbortError") return true; + return false; +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/proxy-integration.ts b/src/app/v1/_lib/proxy/fake-streaming/proxy-integration.ts new file mode 100644 index 000000000..c67073137 --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/proxy-integration.ts @@ -0,0 +1,144 @@ +import { logger } from "@/lib/logger"; +import type { SystemSettings } from "@/types/system-config"; +import type { ClientFormat } from "../format-mapper"; +import { ProxyForwarder } from "../forwarder"; +import type { ProxySession } from "../session"; +import { isFakeStreamingEligible } from "./eligibility"; +import type { ProtocolFamily } from "./response-validator"; +import { + type AttemptPerformer, + buildFakeStreamingNonStreamResponse, + buildFakeStreamingResponse, +} from "./runner"; +import { cloneRequestForInternalNonStreamAttempt, detectClientStreamIntent } from "./stream-intent"; + +const HEARTBEAT_INTERVAL_MS = 5000; +// The underlying ProxyForwarder runs its own multi-provider loop with fake-200 +// detection and serial fallback. We only allow ONE invocation of that loop +// per fake-streaming request to avoid double-counting message context, cost, +// and provider chain. Edge cases that slip past the forwarder's fake-200 +// detection (e.g., empty content array, comment-only SSE) end as 502 here +// instead of triggering a second forwarder pass. +const MAX_ATTEMPTS = 1; + +function familyFromFormat(format: ClientFormat): ProtocolFamily | null { + switch (format) { + case "claude": + return "anthropic"; + case "openai": + return "openai-chat"; + case "response": + return "openai-responses"; + case "gemini": + case "gemini-cli": + return "gemini"; + default: + return null; + } +} + +/** + * If the request is eligible for fake streaming, run the fake streaming flow + * and return its Response. Otherwise return null so the caller can fall back + * to the regular ProxyForwarder + ProxyResponseHandler path. + */ +export async function tryFakeStreamingPath( + session: ProxySession, + systemSettings: SystemSettings +): Promise { + const clientModel = (session.request.model ?? "").toString(); + const providerGroup = session.provider?.groupTag ?? null; + const eligible = isFakeStreamingEligible( + clientModel, + providerGroup, + systemSettings.fakeStreamingWhitelist + ); + if (!eligible) return null; + + const family = familyFromFormat(session.originalFormat); + if (!family) return null; + + const isStream = detectClientStreamIntent({ + format: session.originalFormat, + pathname: session.requestUrl.pathname, + search: session.requestUrl.search, + body: session.request.message, + }); + + // Convert the session request to a non-stream upstream attempt before the + // forwarder runs. We never let upstream open a streaming response because + // we need to fully buffer + validate before emitting anything. + applyNonStreamMutation(session); + + const performAttempt = buildAttemptPerformer(session); + const abortSignal = session.clientAbortSignal ?? new AbortController().signal; + + if (isStream) { + logger.debug("[FakeStreaming] taking stream path", { + model: clientModel, + providerGroup, + family, + }); + return buildFakeStreamingResponse({ + family, + isStream: true, + performAttempt, + abortSignal, + maxAttempts: MAX_ATTEMPTS, + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + }); + } + + logger.debug("[FakeStreaming] taking non-stream path", { + model: clientModel, + providerGroup, + family, + }); + return await buildFakeStreamingNonStreamResponse({ + family, + performAttempt, + abortSignal, + maxAttempts: MAX_ATTEMPTS, + }); +} + +function applyNonStreamMutation(session: ProxySession): void { + const cloned = cloneRequestForInternalNonStreamAttempt({ + format: session.originalFormat, + pathname: session.requestUrl.pathname, + search: session.requestUrl.search, + body: session.request.message, + }); + if (cloned.body) { + // Mutate in place so downstream forwarder uses the non-stream body. + for (const key of Object.keys(session.request.message)) { + delete (session.request.message as Record)[key]; + } + Object.assign(session.request.message, cloned.body); + } + + if ( + cloned.pathname !== session.requestUrl.pathname || + cloned.search !== session.requestUrl.search + ) { + const next = new URL(session.requestUrl.toString()); + next.pathname = cloned.pathname; + next.search = cloned.search; + session.requestUrl = next; + } +} + +function buildAttemptPerformer(session: ProxySession): AttemptPerformer { + return async (_attemptIndex, abortSignal) => { + if (abortSignal.aborted) { + throw Object.assign(new Error("aborted"), { name: "AbortError" }); + } + const response = await ProxyForwarder.send(session); + const body = await response.text(); + return { + status: response.status, + body, + providerId: session.provider?.id != null ? String(session.provider.id) : "unknown", + }; + }; +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts b/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts new file mode 100644 index 000000000..59493e643 --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/response-validator.ts @@ -0,0 +1,373 @@ +export type ProtocolFamily = "anthropic" | "openai-chat" | "openai-responses" | "gemini"; + +export type ValidationFailureCode = + | "non_2xx_status" + | "empty_body" + | "invalid_json" + | "stream_no_events" + | "stream_done_only" + | "stream_error_event" + | "stream_no_deliverable" + | "missing_required_field" + | "no_deliverable_content"; + +export interface ValidationResult { + ok: boolean; + code?: ValidationFailureCode; + reason?: string; +} + +export interface ValidateInput { + family: ProtocolFamily; + status: number; + body: string; + isStream: boolean; +} + +const SUCCESS: ValidationResult = { ok: true }; + +export function validateUpstreamResponse(input: ValidateInput): ValidationResult { + if (input.status < 200 || input.status >= 300) { + return fail("non_2xx_status", `upstream status=${input.status}`); + } + + const trimmed = input.body.trim(); + if (trimmed.length === 0) { + return fail("empty_body", "body is empty / whitespace only"); + } + + if (input.isStream) { + return validateStream(input.family, input.body); + } + + return validateNonStream(input.family, input.body); +} + +function validateNonStream(family: ProtocolFamily, body: string): ValidationResult { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return fail("invalid_json", "non-stream body is not valid JSON"); + } + if (!parsed || typeof parsed !== "object") { + return fail("invalid_json", "non-stream body did not parse to an object"); + } + + switch (family) { + case "anthropic": + return validateAnthropicMessage(parsed); + case "openai-chat": + return validateOpenAIChatCompletion(parsed); + case "openai-responses": + return validateOpenAIResponses(parsed); + case "gemini": + return validateGeminiNonStream(parsed); + } +} + +function validateAnthropicMessage(parsed: unknown): ValidationResult { + const obj = parsed as { content?: unknown }; + if (!Array.isArray(obj.content)) { + return fail("missing_required_field", "anthropic response missing content array"); + } + if (obj.content.length === 0) { + return fail("no_deliverable_content", "anthropic content array is empty"); + } + for (const block of obj.content) { + if (!block || typeof block !== "object") continue; + const typedBlock = block as { type?: unknown; text?: unknown; input?: unknown }; + if (typedBlock.type === "text" && isNonEmptyString(typedBlock.text)) return SUCCESS; + if (typedBlock.type === "tool_use") return SUCCESS; + if (typedBlock.type === "thinking") { + // thinking by itself is not deliverable; keep scanning + continue; + } + if (typeof typedBlock.type === "string" && typedBlock.type.length > 0) { + // Unknown but typed block: accept as deliverable. + return SUCCESS; + } + } + return fail("no_deliverable_content", "anthropic content has no deliverable block"); +} + +function validateOpenAIChatCompletion(parsed: unknown): ValidationResult { + const obj = parsed as { choices?: unknown }; + if (!Array.isArray(obj.choices)) { + return fail("missing_required_field", "openai-chat response missing choices"); + } + if (obj.choices.length === 0) { + return fail("no_deliverable_content", "openai-chat choices array is empty"); + } + for (const choice of obj.choices) { + if (!choice || typeof choice !== "object") continue; + const message = (choice as { message?: unknown }).message; + if (!message || typeof message !== "object") continue; + if (chatMessageHasDeliverable(message)) return SUCCESS; + } + return fail("no_deliverable_content", "openai-chat choices have no deliverable message"); +} + +function chatMessageHasDeliverable(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + const typed = message as { + content?: unknown; + tool_calls?: unknown; + function_call?: unknown; + refusal?: unknown; + }; + if (isNonEmptyString(typed.content)) return true; + if (Array.isArray(typed.content) && typed.content.length > 0) return true; + if (Array.isArray(typed.tool_calls) && typed.tool_calls.length > 0) return true; + if (typed.function_call && typeof typed.function_call === "object") return true; + return false; +} + +function validateOpenAIResponses(parsed: unknown): ValidationResult { + const obj = parsed as { output?: unknown }; + if (!Array.isArray(obj.output)) { + return fail("missing_required_field", "openai-responses missing output array"); + } + if (obj.output.length === 0) { + return fail("no_deliverable_content", "openai-responses output array is empty"); + } + for (const item of obj.output) { + if (!item || typeof item !== "object") continue; + const typedItem = item as { type?: unknown; content?: unknown }; + if (typedItem.type === "message" && Array.isArray(typedItem.content)) { + for (const part of typedItem.content) { + if (!part || typeof part !== "object") continue; + const partTyped = part as { type?: unknown; text?: unknown }; + if (partTyped.type === "output_text" && isNonEmptyString(partTyped.text)) return SUCCESS; + if (typeof partTyped.type === "string" && partTyped.type.length > 0) return SUCCESS; + } + } + if (typeof typedItem.type === "string" && typedItem.type !== "message") { + // function_call, custom_tool_call_output, reasoning, etc. — all deliverable. + return SUCCESS; + } + } + return fail("no_deliverable_content", "openai-responses output has no deliverable item"); +} + +function validateGeminiNonStream(parsed: unknown): ValidationResult { + const obj = parsed as { candidates?: unknown }; + if (!Array.isArray(obj.candidates)) { + return fail("missing_required_field", "gemini response missing candidates array"); + } + if (obj.candidates.length === 0) { + return fail("no_deliverable_content", "gemini candidates array is empty"); + } + for (const candidate of obj.candidates) { + if (!candidate || typeof candidate !== "object") continue; + const content = (candidate as { content?: unknown }).content; + if (!content || typeof content !== "object") continue; + const parts = (content as { parts?: unknown }).parts; + if (!Array.isArray(parts) || parts.length === 0) continue; + for (const part of parts) { + if (!part || typeof part !== "object") continue; + const typed = part as Record; + if (isNonEmptyString(typed.text)) return SUCCESS; + if (typed.inlineData && typeof typed.inlineData === "object") return SUCCESS; + if (typed.fileData && typeof typed.fileData === "object") return SUCCESS; + if (typed.functionCall && typeof typed.functionCall === "object") return SUCCESS; + if (typed.functionResponse && typeof typed.functionResponse === "object") return SUCCESS; + if (typed.executableCode && typeof typed.executableCode === "object") return SUCCESS; + if (typed.codeExecutionResult && typeof typed.codeExecutionResult === "object") + return SUCCESS; + } + } + return fail("no_deliverable_content", "gemini candidates have no deliverable part"); +} + +function validateStream(family: ProtocolFamily, body: string): ValidationResult { + const events = collectSseEvents(body); + if (events.length === 0) { + return fail("stream_no_events", "stream contained no events (comments / blanks only)"); + } + + let sawDone = false; + let sawError = false; + let sawDeliverable = false; + + for (const event of events) { + if (event.kind === "done") { + sawDone = true; + continue; + } + if (event.kind === "error") { + sawError = true; + continue; + } + if (event.eventName === "error") { + sawError = true; + continue; + } + const json = parseJsonSafe(event.data); + if (!json || typeof json !== "object") continue; + if (eventCarriesDeliverable(family, event.eventName, json)) { + sawDeliverable = true; + } + } + + if (sawDeliverable) return SUCCESS; + if (sawError) return fail("stream_error_event", "stream contained an error event"); + if (sawDone) return fail("stream_done_only", "stream contained only [DONE]"); + return fail("stream_no_deliverable", "stream had no deliverable events"); +} + +function eventCarriesDeliverable( + family: ProtocolFamily, + eventName: string | null, + json: object +): boolean { + if (family === "anthropic") { + const typed = json as { type?: unknown; delta?: unknown; content_block?: unknown }; + if (typed.type === "error") return false; + if (typed.type === "content_block_delta") { + const delta = typed.delta as { type?: unknown; text?: unknown } | undefined; + if ( + delta && + (isNonEmptyString(delta.text) || + isNonEmptyString((delta as { partial_json?: unknown }).partial_json)) + ) { + return true; + } + } + if (typed.type === "content_block_start") { + const block = typed.content_block as { type?: unknown; text?: unknown } | undefined; + if (block && typeof block.type === "string" && block.type.length > 0) { + if (block.type !== "text" || isNonEmptyString(block.text)) { + // Non-text blocks (tool_use etc.) count immediately; text blocks need delta to confirm content. + if (block.type !== "text") return true; + } + } + } + return false; + } + + if (family === "openai-chat") { + const choices = (json as { choices?: unknown }).choices; + if (!Array.isArray(choices)) return false; + for (const choice of choices) { + if (!choice || typeof choice !== "object") continue; + const delta = (choice as { delta?: unknown }).delta; + if (!delta || typeof delta !== "object") continue; + const typed = delta as { content?: unknown; tool_calls?: unknown; function_call?: unknown }; + if (isNonEmptyString(typed.content)) return true; + if (Array.isArray(typed.content) && typed.content.length > 0) return true; + if (Array.isArray(typed.tool_calls) && typed.tool_calls.length > 0) return true; + if (typed.function_call && typeof typed.function_call === "object") return true; + } + return false; + } + + if (family === "openai-responses") { + const typed = json as { type?: unknown }; + if (typeof typed.type !== "string") return false; + if (typed.type === "response.error") return false; + if (typed.type === "response.output_text.delta") { + const delta = (json as { delta?: unknown }).delta; + if (isNonEmptyString(delta)) return true; + } + if (typed.type === "response.output_item.added" || typed.type === "response.output_item.done") { + return true; + } + if (typed.type === "response.completed") { + const response = (json as { response?: unknown }).response; + if (response && typeof response === "object") { + const output = (response as { output?: unknown }).output; + if (Array.isArray(output) && output.length > 0) return true; + } + } + if (typed.type === "response.created" && eventName !== "response.created") { + return true; + } + return false; + } + + if (family === "gemini") { + const candidates = (json as { candidates?: unknown }).candidates; + if (!Array.isArray(candidates) || candidates.length === 0) return false; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object") continue; + const content = (candidate as { content?: unknown }).content; + if (!content || typeof content !== "object") continue; + const parts = (content as { parts?: unknown }).parts; + if (Array.isArray(parts) && parts.length > 0) return true; + } + return false; + } + + return false; +} + +interface SseEvent { + kind: "data" | "done" | "error"; + eventName: string | null; + data: string; +} + +function collectSseEvents(body: string): SseEvent[] { + const events: SseEvent[] = []; + const dataLines: string[] = []; + let currentEvent: string | null = null; + + const flush = () => { + if (dataLines.length === 0) { + currentEvent = null; + return; + } + const payload = dataLines.join("\n").trim(); + dataLines.length = 0; + const event = currentEvent; + currentEvent = null; + if (!payload) return; + if (payload === "[DONE]") { + events.push({ kind: "done", eventName: event, data: payload }); + return; + } + if (event === "error") { + events.push({ kind: "error", eventName: event, data: payload }); + return; + } + events.push({ kind: "data", eventName: event, data: payload }); + }; + + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine; + if (line.length === 0) { + flush(); + continue; + } + if (line.startsWith(":")) continue; // SSE comment + if (line.startsWith("event:")) { + currentEvent = line.slice(6).trim(); + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).replace(/^\s/, "")); + continue; + } + if (line.startsWith("id:") || line.startsWith("retry:")) { + } + } + flush(); + return events; +} + +function parseJsonSafe(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function fail(code: ValidationFailureCode, reason: string): ValidationResult { + return { ok: false, code, reason }; +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/runner.ts b/src/app/v1/_lib/proxy/fake-streaming/runner.ts new file mode 100644 index 000000000..73f52eb35 --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/runner.ts @@ -0,0 +1,225 @@ +import { emitFinalNonStream, emitFinalStream, emitStreamError } from "./emitters"; +import { type AttemptPerformer, orchestrateFakeStreamingAttempts } from "./orchestrator"; +import type { ProtocolFamily } from "./response-validator"; + +export type { AttemptPerformer } from "./orchestrator"; + +const HEARTBEAT_FRAME = ": ping\n\n"; + +export interface FakeStreamingRunInput { + family: ProtocolFamily; + isStream: boolean; + performAttempt: AttemptPerformer; + abortSignal: AbortSignal; + maxAttempts: number; + heartbeatIntervalMs: number; +} + +/** + * Synchronous entry for the stream client path: returns a Response immediately + * so the SSE heartbeat can flush before the orchestrator finishes. + * + * For non-stream clients, callers should use `buildFakeStreamingNonStreamResponse` + * which awaits the orchestrator and returns an accurate HTTP status code. + */ +export function buildFakeStreamingResponse(input: FakeStreamingRunInput): Response { + if (input.isStream) { + return buildStreamResponse(input); + } + // Tests / callers that pass `isStream: false` here get a placeholder 200 + // response whose body resolves once the orchestrator settles; for accurate + // HTTP status, prefer `buildFakeStreamingNonStreamResponse`. + return buildLegacyNonStreamPlaceholder(input); +} + +function buildStreamResponse(input: FakeStreamingRunInput): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + let closed = false; + const safeEnqueue = (chunk: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(chunk)); + } catch { + closed = true; + } + }; + const safeClose = () => { + if (closed) return; + closed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + }; + + // First heartbeat goes out immediately so consumers see something on the + // wire right away. + safeEnqueue(HEARTBEAT_FRAME); + + const heartbeatTimer = setInterval(() => { + safeEnqueue(HEARTBEAT_FRAME); + }, input.heartbeatIntervalMs); + + const cleanupHeartbeat = () => clearInterval(heartbeatTimer); + + const onAbort = () => { + cleanupHeartbeat(); + safeClose(); + }; + input.abortSignal.addEventListener("abort", onAbort, { once: true }); + + void orchestrateFakeStreamingAttempts({ + family: input.family, + performAttempt: input.performAttempt, + abortSignal: input.abortSignal, + maxAttempts: input.maxAttempts, + }) + .then((result) => { + cleanupHeartbeat(); + input.abortSignal.removeEventListener("abort", onAbort); + if (input.abortSignal.aborted) { + safeClose(); + return; + } + if (result.ok && typeof result.finalBody === "string") { + try { + safeEnqueue(emitFinalStream({ family: input.family, finalBody: result.finalBody })); + } catch { + safeEnqueue( + emitStreamError({ + family: input.family, + errorMessage: "fake streaming emitter failed", + errorCode: "emitter_error", + }) + ); + } + } else if (result.errorCode === "client_abort") { + // Already handled by abort listener; nothing to emit. + } else { + safeEnqueue( + emitStreamError({ + family: input.family, + errorMessage: result.errorMessage ?? "all upstream attempts failed", + errorCode: result.errorCode ?? "upstream_all_attempts_failed", + }) + ); + } + safeClose(); + }) + .catch((error: unknown) => { + cleanupHeartbeat(); + input.abortSignal.removeEventListener("abort", onAbort); + if (!input.abortSignal.aborted) { + safeEnqueue( + emitStreamError({ + family: input.family, + errorMessage: + error instanceof Error ? error.message : "fake streaming runner failed", + errorCode: "runner_error", + }) + ); + } + safeClose(); + }); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} + +/** + * Placeholder for the synchronous `isStream: false` path. Status is fixed at + * 200; callers that need accurate HTTP status should use + * `buildFakeStreamingNonStreamResponse`. + */ +function buildLegacyNonStreamPlaceholder(input: FakeStreamingRunInput): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const result = await orchestrateFakeStreamingAttempts({ + family: input.family, + performAttempt: input.performAttempt, + abortSignal: input.abortSignal, + maxAttempts: input.maxAttempts, + }); + const body = + result.ok && typeof result.finalBody === "string" + ? emitFinalNonStream({ family: input.family, finalBody: result.finalBody }) + : JSON.stringify({ + error: { + code: result.errorCode ?? "upstream_all_attempts_failed", + message: result.errorMessage ?? "all upstream attempts failed", + }, + }); + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); +} + +/** + * Strict Promise variant for non-stream path. Always resolves with + * an accurate HTTP status code (200 success / 502 all-failed / 499 abort). + */ +export async function buildFakeStreamingNonStreamResponse( + input: Omit +): Promise { + const result = await orchestrateFakeStreamingAttempts({ + family: input.family, + performAttempt: input.performAttempt, + abortSignal: input.abortSignal, + maxAttempts: input.maxAttempts, + }); + + if (result.ok && typeof result.finalBody === "string") { + return new Response(emitFinalNonStream({ family: input.family, finalBody: result.finalBody }), { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }); + } + + if (result.errorCode === "client_abort") { + return new Response( + JSON.stringify({ + error: { + code: "client_abort", + message: result.errorMessage ?? "client disconnected", + }, + }), + { + status: 499, + headers: { "Content-Type": "application/json; charset=utf-8" }, + } + ); + } + + return new Response( + JSON.stringify({ + error: { + code: result.errorCode ?? "upstream_all_attempts_failed", + message: result.errorMessage ?? "all upstream attempts failed", + }, + }), + { + status: 502, + headers: { "Content-Type": "application/json; charset=utf-8" }, + } + ); +} diff --git a/src/app/v1/_lib/proxy/fake-streaming/stream-intent.ts b/src/app/v1/_lib/proxy/fake-streaming/stream-intent.ts new file mode 100644 index 000000000..cbab6540b --- /dev/null +++ b/src/app/v1/_lib/proxy/fake-streaming/stream-intent.ts @@ -0,0 +1,110 @@ +import type { ClientFormat } from "../format-mapper"; + +export interface StreamIntentInputs { + format: ClientFormat; + pathname: string; + search: string; + body: Record | null; +} + +const GEMINI_FORMATS: ReadonlyArray = ["gemini", "gemini-cli"]; + +function isGeminiFamily(format: ClientFormat): boolean { + return GEMINI_FORMATS.includes(format); +} + +function bodyStreamFlag(body: Record | null): boolean { + return body !== null && body.stream === true; +} + +/** + * Detect whether the client requested a streaming response. + * + * Standard formats (claude / openai / response): only `body.stream === true` + * counts. Path / query are ignored. + * + * Gemini family (gemini / gemini-cli): any of `:streamGenerateContent` in + * pathname, `alt=sse` query, or `body.stream === true` counts. + * + * Inputs come from already-parsed request state — this helper does not consume + * request body streams. + */ +export function detectClientStreamIntent(input: StreamIntentInputs): boolean { + if (isGeminiFamily(input.format)) { + if (input.pathname.includes(":streamGenerateContent")) return true; + if (hasAltSse(input.search)) return true; + return bodyStreamFlag(input.body); + } + return bodyStreamFlag(input.body); +} + +function hasAltSse(search: string): boolean { + if (!search) return false; + // search may or may not include leading "?"; URLSearchParams handles both. + const normalized = search.startsWith("?") ? search.slice(1) : search; + if (!normalized) return false; + try { + const params = new URLSearchParams(normalized); + return params.get("alt") === "sse"; + } catch { + return false; + } +} + +/** + * Produce a non-stream variant of the request inputs without mutating the + * originals. Used when the client wants a stream but fake streaming wants to + * call upstream with a buffered, non-stream attempt. + */ +export function cloneRequestForInternalNonStreamAttempt( + input: StreamIntentInputs +): StreamIntentInputs { + if (isGeminiFamily(input.format)) { + const newPath = input.pathname.replace(":streamGenerateContent", ":generateContent"); + const newSearch = stripAltSse(input.search); + const newBody = cloneBodyWithoutStreamFlag(input.body); + return { format: input.format, pathname: newPath, search: newSearch, body: newBody }; + } + + return { + format: input.format, + pathname: input.pathname, + search: input.search, + body: cloneBodyWithStreamFalse(input.body), + }; +} + +function stripAltSse(search: string): string { + if (!search) return ""; + const hasLeadingQuestion = search.startsWith("?"); + const raw = hasLeadingQuestion ? search.slice(1) : search; + if (!raw) return ""; + try { + const params = new URLSearchParams(raw); + if (params.get("alt") === "sse") { + params.delete("alt"); + } + const remaining = params.toString(); + if (!remaining) return ""; + return hasLeadingQuestion ? `?${remaining}` : remaining; + } catch { + return search; + } +} + +function cloneBodyWithStreamFalse( + body: Record | null +): Record | null { + if (body === null) return null; + return { ...body, stream: false }; +} + +function cloneBodyWithoutStreamFlag( + body: Record | null +): Record | null { + if (body === null) return null; + if (!("stream" in body)) { + return { ...body }; + } + return { ...body, stream: false }; +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 04623fecd..a9efb7625 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -799,6 +799,12 @@ export const systemSettings = pgTable('system_settings', { .notNull() .default(true), + // Fake 流式输出白名单(缺省 NULL → transformer 落 DEFAULT_FAKE_STREAMING_WHITELIST; + // 显式 [] → 表示禁用 fake streaming) + fakeStreamingWhitelist: jsonb('fake_streaming_whitelist').$type< + Array<{ model: string; groupTags: string[] }> + >(), + // Codex Session ID 补全(默认开启) // 开启后:当 Codex 请求缺少 session_id / prompt_cache_key 时,自动补全或生成稳定的会话标识 enableCodexSessionIdCompletion: boolean('enable_codex_session_id_completion') diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 51b5e200e..f8b537a9a 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -43,6 +43,7 @@ const DEFAULT_SETTINGS: Pick< | "enableBillingHeaderRectifier" | "enableResponseInputRectifier" | "allowNonConversationEndpointProviderFallback" + | "fakeStreamingWhitelist" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" @@ -61,6 +62,9 @@ const DEFAULT_SETTINGS: Pick< enableResponseInputRectifier: true, // 安全敏感开关:冷缓存 / DB 读取失败时 fail-closed,避免意外重新开启跨供应商 raw fallback。 allowNonConversationEndpointProviderFallback: false, + // Fake streaming 在 DB 完全不可达时 fail-closed(空白名单 → 走原有直传路径), + // 避免在不确定状态下劫持流式。Transformer / createFallbackSettings 仍走 4 个默认模型。 + fakeStreamingWhitelist: [], enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, @@ -143,6 +147,7 @@ export async function getCachedSystemSettings(): Promise { enableResponseInputRectifier: DEFAULT_SETTINGS.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: DEFAULT_SETTINGS.allowNonConversationEndpointProviderFallback, + fakeStreamingWhitelist: DEFAULT_SETTINGS.fakeStreamingWhitelist, enableCodexSessionIdCompletion: DEFAULT_SETTINGS.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: DEFAULT_SETTINGS.enableClaudeMetadataUserIdInjection, enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index a20b3094e..32a8d52a0 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -974,6 +974,43 @@ export const UpdateSystemSettingsSchema = z.object({ enableResponseInputRectifier: z.boolean().optional(), // 非对话端点跨供应商 fallback(可选) allowNonConversationEndpointProviderFallback: z.boolean().optional(), + // Fake 流式输出白名单(可选)。空数组表示显式禁用;缺省 → 使用默认四个图像生成模型。 + fakeStreamingWhitelist: z + .array( + z.object({ + model: z + .string() + .min(1, "model 不能为空") + .max(200, "model 不能超过 200 个字符") + .transform((value) => value.trim()) + .refine((value) => value.length > 0, { message: "model 不能为空" }), + groupTags: z + .array( + z + .string() + .min(1) + .transform((value) => value.trim()) + .refine((value) => value.length > 0, { message: "groupTag 不能为空" }) + ) + .default([]) + .transform((tags) => Array.from(new Set(tags))), + }) + ) + .superRefine((entries, ctx) => { + const seen = new Set(); + for (let index = 0; index < entries.length; index += 1) { + const model = entries[index].model; + if (seen.has(model)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `fakeStreamingWhitelist 模型重复: ${model}`, + path: [index, "model"], + }); + } + seen.add(model); + } + }) + .optional(), // Codex Session ID 补全(可选) enableCodexSessionIdCompletion: z.boolean().optional(), // Claude metadata.user_id 注入(可选) diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 6849bd77f..dca1ae147 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -5,7 +5,12 @@ import type { Key } from "@/types/key"; import type { MessageRequest } from "@/types/message"; import type { ModelPrice } from "@/types/model-price"; import type { Provider } from "@/types/provider"; -import type { ResponseFixerConfig, SystemSettings } from "@/types/system-config"; +import { + DEFAULT_FAKE_STREAMING_WHITELIST, + type FakeStreamingWhitelistEntry, + type ResponseFixerConfig, + type SystemSettings, +} from "@/types/system-config"; import type { User } from "@/types/user"; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -189,6 +194,44 @@ export function toModelPrice(dbPrice: any): ModelPrice { }; } +function normalizeFakeStreamingWhitelist(value: unknown): FakeStreamingWhitelistEntry[] { + // null / undefined → use legacy default; persisted [] is preserved as opt-out. + if (value === undefined || value === null) { + return DEFAULT_FAKE_STREAMING_WHITELIST.map((entry) => ({ + model: entry.model, + groupTags: [...entry.groupTags], + })); + } + if (!Array.isArray(value)) { + return DEFAULT_FAKE_STREAMING_WHITELIST.map((entry) => ({ + model: entry.model, + groupTags: [...entry.groupTags], + })); + } + const seen = new Set(); + const result: FakeStreamingWhitelistEntry[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") continue; + const candidate = raw as { model?: unknown; groupTags?: unknown }; + const model = typeof candidate.model === "string" ? candidate.model.trim() : ""; + if (model.length === 0 || seen.has(model)) continue; + seen.add(model); + const groupTags: string[] = []; + if (Array.isArray(candidate.groupTags)) { + const groupSeen = new Set(); + for (const tag of candidate.groupTags) { + if (typeof tag !== "string") continue; + const trimmed = tag.trim(); + if (trimmed.length === 0 || groupSeen.has(trimmed)) continue; + groupSeen.add(trimmed); + groupTags.push(trimmed); + } + } + result.push({ model, groupTags }); + } + return result; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function toSystemSettings(dbSettings: any): SystemSettings { const defaultResponseFixerConfig: ResponseFixerConfig = { @@ -227,6 +270,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { enableResponseInputRectifier: dbSettings?.enableResponseInputRectifier ?? true, allowNonConversationEndpointProviderFallback: dbSettings?.allowNonConversationEndpointProviderFallback ?? true, + fakeStreamingWhitelist: normalizeFakeStreamingWhitelist(dbSettings?.fakeStreamingWhitelist), enableCodexSessionIdCompletion: dbSettings?.enableCodexSessionIdCompletion ?? true, enableClaudeMetadataUserIdInjection: dbSettings?.enableClaudeMetadataUserIdInjection ?? true, enableResponseFixer: dbSettings?.enableResponseFixer ?? true, diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 91006166a..501bda887 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -5,7 +5,11 @@ import { db } from "@/drizzle/db"; import { systemSettings } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; import { DEFAULT_SITE_TITLE } from "@/lib/site-title"; -import type { SystemSettings, UpdateSystemSettingsInput } from "@/types/system-config"; +import { + DEFAULT_FAKE_STREAMING_WHITELIST, + type SystemSettings, + type UpdateSystemSettingsInput, +} from "@/types/system-config"; import { toSystemSettings } from "./_shared/transformers"; type TransactionExecutor = Parameters[0]>[0]; @@ -159,6 +163,10 @@ function createFallbackSettings(): SystemSettings { enableBillingHeaderRectifier: true, enableResponseInputRectifier: true, allowNonConversationEndpointProviderFallback: true, + fakeStreamingWhitelist: DEFAULT_FAKE_STREAMING_WHITELIST.map((entry) => ({ + model: entry.model, + groupTags: [...entry.groupTags], + })), enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, @@ -270,6 +278,7 @@ export async function getSystemSettings(): Promise { }; const fullSelection = { passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, + fakeStreamingWhitelist: systemSettings.fakeStreamingWhitelist, ...selectionWithoutPassThrough, }; @@ -287,12 +296,36 @@ export async function getSystemSettings(): Promise { error, }); - // 第一层降级:仅移除本次新增的 allowNonConversationEndpointProviderFallback 列, + // 第一层降级:仅移除本次新增的 fakeStreamingWhitelist 列, // 其它已迁移的现代字段保留,避免只缺该列时其它设置被连带默认化。 + const { + fakeStreamingWhitelist: _omitFakeStreamingWhitelist, + ...selectionWithoutFakeStreamingWhitelist + } = fullSelection; + + try { + const [row] = await db + .select(selectionWithoutFakeStreamingWhitelist) + .from(systemSettings) + .orderBy(asc(systemSettings.id)) + .limit(1); + return row ?? null; + } catch (fakeStreamingFallbackError) { + if (!isUndefinedColumnError(fakeStreamingFallbackError)) { + throw fakeStreamingFallbackError; + } + + logger.warn( + "system_settings 表除 fakeStreamingWhitelist 外仍有列缺失,继续回退到上一代字段集。", + { error: fakeStreamingFallbackError } + ); + } + + // 第二层降级:再移除 allowNonConversationEndpointProviderFallback 列。 const { allowNonConversationEndpointProviderFallback: _omitNonConversationFallback, ...selectionWithoutNonConversationFallback - } = fullSelection; + } = selectionWithoutFakeStreamingWhitelist; try { const [row] = await db @@ -533,6 +566,7 @@ export async function updateSystemSettings( }; const fullReturning = { passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, + fakeStreamingWhitelist: systemSettings.fakeStreamingWhitelist, ...returningWithoutPassThrough, }; @@ -698,6 +732,11 @@ export async function updateSystemSettings( updates.ipGeoLookupEnabled = payload.ipGeoLookupEnabled; } + // Fake 流式输出白名单(如果提供;空数组表示显式禁用,null 留待 transformer 落默认) + if (payload.fakeStreamingWhitelist !== undefined) { + updates.fakeStreamingWhitelist = payload.fakeStreamingWhitelist; + } + let updated; try { [updated] = await executor @@ -714,87 +753,116 @@ export async function updateSystemSettings( error, }); - // 第一层降级:仅移除本次新增的 allowNonConversationEndpointProviderFallback 列, - // 其它字段继续原值更新 / 返回,避免只缺该列时连带丢失 codex/highConcurrency 等更新。 + // 第一层降级:仅移除本次新增的 fakeStreamingWhitelist 列, + // 其它字段继续原值更新 / 返回。 const { - allowNonConversationEndpointProviderFallback: _omitUpdate, - ...updatesWithoutNonConversationFallback + fakeStreamingWhitelist: _omitUpdateFakeStreaming, + ...updatesWithoutFakeStreamingWhitelist } = updates; const { - allowNonConversationEndpointProviderFallback: _omitReturning, - ...returningWithoutNonConversationFallback + fakeStreamingWhitelist: _omitReturningFakeStreaming, + ...returningWithoutFakeStreamingWhitelist } = fullReturning; try { [updated] = await executor .update(systemSettings) - .set(updatesWithoutNonConversationFallback) + .set(updatesWithoutFakeStreamingWhitelist) .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutNonConversationFallback); - } catch (nonConversationFallbackError) { - if (!isUndefinedColumnError(nonConversationFallbackError)) { - throw nonConversationFallbackError; + .returning(returningWithoutFakeStreamingWhitelist); + } catch (fakeStreamingFallbackError) { + if (!isUndefinedColumnError(fakeStreamingFallbackError)) { + throw fakeStreamingFallbackError; } logger.warn( - "system_settings 表除新增列外仍有列缺失,继续回退到 passThrough / highConcurrency 字段集更新。", - { error: nonConversationFallbackError } + "system_settings 表除 fakeStreamingWhitelist 外仍有列缺失,继续回退到 allowNonConversationEndpointProviderFallback 之外的字段集。", + { error: fakeStreamingFallbackError } ); + } + + // 第二层降级:再移除 allowNonConversationEndpointProviderFallback 列。 + const { + allowNonConversationEndpointProviderFallback: _omitUpdate, + ...updatesWithoutNonConversationFallback + } = updatesWithoutFakeStreamingWhitelist; + const { + allowNonConversationEndpointProviderFallback: _omitReturning, + ...returningWithoutNonConversationFallback + } = returningWithoutFakeStreamingWhitelist; + if (!updated) { try { - const withoutPassThroughUpdates = { ...updates }; - delete withoutPassThroughUpdates.passThroughUpstreamErrorMessage; [updated] = await executor .update(systemSettings) - .set(withoutPassThroughUpdates) + .set(updatesWithoutNonConversationFallback) .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutPassThrough); - } catch (passThroughFallbackError) { - if (!isUndefinedColumnError(passThroughFallbackError)) { - throw passThroughFallbackError; + .returning(returningWithoutNonConversationFallback); + } catch (nonConversationFallbackError) { + if (!isUndefinedColumnError(nonConversationFallbackError)) { + throw nonConversationFallbackError; } - const downgradedUpdates = { ...updates }; - delete downgradedUpdates.passThroughUpstreamErrorMessage; - delete downgradedUpdates.enableHighConcurrencyMode; - delete downgradedUpdates.publicStatusWindowHours; - delete downgradedUpdates.publicStatusAggregationIntervalMinutes; - delete downgradedUpdates.ipExtractionConfig; - delete downgradedUpdates.ipGeoLookupEnabled; - - const legacyUpdates = { ...downgradedUpdates }; - delete legacyUpdates.codexPriorityBillingSource; - delete legacyUpdates.allowNonConversationEndpointProviderFallback; + logger.warn( + "system_settings 表除新增列外仍有列缺失,继续回退到 passThrough / highConcurrency 字段集更新。", + { error: nonConversationFallbackError } + ); try { + const withoutPassThroughUpdates = { ...updates }; + delete withoutPassThroughUpdates.passThroughUpstreamErrorMessage; [updated] = await executor .update(systemSettings) - .set(downgradedUpdates) + .set(withoutPassThroughUpdates) .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutHighConcurrencyMode); - } catch (downgradedFallbackError) { - if (!isUndefinedColumnError(downgradedFallbackError)) { - throw downgradedFallbackError; + .returning(returningWithoutPassThrough); + } catch (passThroughFallbackError) { + if (!isUndefinedColumnError(passThroughFallbackError)) { + throw passThroughFallbackError; } - logger.warn( - "system_settings 表缺少 codexPriorityBillingSource 之外的新列,继续降级重试。", - { error: downgradedFallbackError } - ); + const downgradedUpdates = { ...updates }; + delete downgradedUpdates.passThroughUpstreamErrorMessage; + delete downgradedUpdates.enableHighConcurrencyMode; + delete downgradedUpdates.publicStatusWindowHours; + delete downgradedUpdates.publicStatusAggregationIntervalMinutes; + delete downgradedUpdates.ipExtractionConfig; + delete downgradedUpdates.ipGeoLookupEnabled; - [updated] = await executor - .update(systemSettings) - .set(legacyUpdates) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutCodexAndHighConcurrency); - } + const legacyUpdates = { ...downgradedUpdates }; + delete legacyUpdates.codexPriorityBillingSource; + delete legacyUpdates.allowNonConversationEndpointProviderFallback; - if (!updated) { - [updated] = await executor - .update(systemSettings) - .set(legacyUpdates) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutCodexAndHighConcurrency); + try { + [updated] = await executor + .update(systemSettings) + .set(downgradedUpdates) + .where(eq(systemSettings.id, current.id)) + .returning(returningWithoutHighConcurrencyMode); + } catch (downgradedFallbackError) { + if (!isUndefinedColumnError(downgradedFallbackError)) { + throw downgradedFallbackError; + } + + logger.warn( + "system_settings 表缺少 codexPriorityBillingSource 之外的新列,继续降级重试。", + { error: downgradedFallbackError } + ); + + [updated] = await executor + .update(systemSettings) + .set(legacyUpdates) + .where(eq(systemSettings.id, current.id)) + .returning(returningWithoutCodexAndHighConcurrency); + } + + if (!updated) { + [updated] = await executor + .update(systemSettings) + .set(legacyUpdates) + .where(eq(systemSettings.id, current.id)) + .returning(returningWithoutCodexAndHighConcurrency); + } } } } diff --git a/src/types/system-config.ts b/src/types/system-config.ts index 704e1703d..a7cb5b235 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -13,6 +13,22 @@ export interface ResponseFixerConfig { maxFixSize: number; } +// Fake streaming whitelist entry: pairs an exact client-requested model name +// with optional provider group tags. Empty groupTags means "all groups". +export interface FakeStreamingWhitelistEntry { + model: string; + groupTags: string[]; +} + +// Default whitelist used when system_settings has no persisted value (legacy +// upgrade path). A persisted empty array is preserved as explicit opt-out. +export const DEFAULT_FAKE_STREAMING_WHITELIST: ReadonlyArray = [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gpt-image-1.5", groupTags: [] }, + { model: "gemini-3.1-flash-image-preview", groupTags: [] }, + { model: "gemini-3-pro-image-preview", groupTags: [] }, +]; + export interface SystemSettings { id: number; siteTitle: string; @@ -79,6 +95,9 @@ export interface SystemSettings { // 当前仅作用于 count_tokens / compact 这两个 raw endpoint allowNonConversationEndpointProviderFallback: boolean; + // Fake 流式输出白名单(缺省时使用 DEFAULT_FAKE_STREAMING_WHITELIST,持久化空数组表示显式禁用) + fakeStreamingWhitelist: FakeStreamingWhitelistEntry[]; + // Codex Session ID 补全(默认开启) // 目标:当 Codex 请求缺少 session_id / prompt_cache_key 时,自动补全或生成稳定的会话标识 enableCodexSessionIdCompletion: boolean; @@ -167,6 +186,9 @@ export interface UpdateSystemSettingsInput { // 非对话端点跨供应商 fallback(可选) allowNonConversationEndpointProviderFallback?: boolean; + // Fake 流式输出白名单(可选) + fakeStreamingWhitelist?: FakeStreamingWhitelistEntry[]; + // Codex Session ID 补全(可选) enableCodexSessionIdCompletion?: boolean; diff --git a/tests/unit/actions/system-config-fake-streaming-setting.test.ts b/tests/unit/actions/system-config-fake-streaming-setting.test.ts new file mode 100644 index 000000000..cc1cbad23 --- /dev/null +++ b/tests/unit/actions/system-config-fake-streaming-setting.test.ts @@ -0,0 +1,341 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const getSystemSettingsMock = vi.fn(); +const loggerWarnMock = vi.fn(); +const invalidateSystemSettingsCacheMock = vi.fn(); +const updateSystemSettingsMock = vi.fn(); +const getSessionMock = vi.fn(); + +vi.mock("server-only", () => ({})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: () => getSystemSettingsMock(), + updateSystemSettings: (...args: unknown[]) => updateSystemSettingsMock(...args), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: loggerWarnMock, + error: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/auth", () => ({ + getSession: () => getSessionMock(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + invalidateSystemSettingsCache: () => invalidateSystemSettingsCacheMock(), + }; +}); + +vi.mock("@/lib/public-status/config-publisher", () => ({ + publishCurrentPublicStatusConfigProjection: vi.fn(async () => ({ + configVersion: "cfg-1", + key: "public-status:v1:config:cfg-1", + written: true, + groupCount: 0, + })), +})); + +vi.mock("@/lib/public-status/rebuild-hints", () => ({ + schedulePublicStatusRebuild: vi.fn(async () => ({ + accepted: true, + rebuildState: "rebuilding", + })), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), + isValidIANATimezone: vi.fn(() => true), +})); + +const DEFAULT_FAKE_STREAMING_MODELS = [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gpt-image-1.5", groupTags: [] }, + { model: "gemini-3.1-flash-image-preview", groupTags: [] }, + { model: "gemini-3-pro-image-preview", groupTags: [] }, +]; + +function createSettings(overrides: Record = {}) { + return { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "requested", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + passThroughUpstreamErrorMessage: true, + enableHttp2: false, + enableHighConcurrencyMode: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, + enableCodexSessionIdCompletion: true, + enableClaudeMetadataUserIdInjection: true, + enableResponseFixer: true, + allowNonConversationEndpointProviderFallback: true, + fakeStreamingWhitelist: DEFAULT_FAKE_STREAMING_MODELS, + 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, + publicStatusWindowHours: 24, + publicStatusAggregationIntervalMinutes: 5, + ipExtractionConfig: null, + ipGeoLookupEnabled: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + ...overrides, + }; +} + +describe("fake streaming whitelist system setting", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + updateSystemSettingsMock.mockResolvedValue(createSettings()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("transformer defaults", () => { + test("defaults missing fake streaming config to requested image models", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + const fromUndefined = toSystemSettings(undefined); + expect(fromUndefined.fakeStreamingWhitelist).toEqual(DEFAULT_FAKE_STREAMING_MODELS); + + const fromNullField = toSystemSettings({ + id: 1, + siteTitle: "Claude Code Hub", + fakeStreamingWhitelist: null, + }); + expect(fromNullField.fakeStreamingWhitelist).toEqual(DEFAULT_FAKE_STREAMING_MODELS); + + const fromMissingField = toSystemSettings({ + id: 1, + siteTitle: "Claude Code Hub", + }); + expect(fromMissingField.fakeStreamingWhitelist).toEqual(DEFAULT_FAKE_STREAMING_MODELS); + }); + + test("preserves empty fake streaming whitelist as explicit opt out", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + const result = toSystemSettings({ + id: 1, + siteTitle: "Claude Code Hub", + fakeStreamingWhitelist: [], + }); + + expect(result.fakeStreamingWhitelist).toEqual([]); + }); + + test("preserves persisted non-empty fake streaming whitelist", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + const persisted = [ + { model: "custom-model-a", groupTags: [] }, + { model: "custom-model-b", groupTags: ["group-a"] }, + ]; + + const result = toSystemSettings({ + id: 1, + siteTitle: "Claude Code Hub", + fakeStreamingWhitelist: persisted, + }); + + expect(result.fakeStreamingWhitelist).toEqual(persisted); + }); + + test("repository fallback (table missing) defaults to image models", async () => { + vi.resetModules(); + vi.doUnmock("@/repository/system-config"); + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn(() => { + const query: Record = {}; + query.from = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => Promise.reject({ code: "42P01" })); + return query; + }), + update: vi.fn(), + insert: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { getSystemSettings } = await import("@/repository/system-config"); + const fallbackSettings = await getSystemSettings(); + expect(fallbackSettings.fakeStreamingWhitelist).toEqual(DEFAULT_FAKE_STREAMING_MODELS); + + // Restore the mock for subsequent tests in this file. + vi.resetModules(); + vi.doMock("@/repository/system-config", () => ({ + getSystemSettings: () => getSystemSettingsMock(), + updateSystemSettings: (...args: unknown[]) => updateSystemSettingsMock(...args), + })); + }); + }); + + describe("validation schema", () => { + test("rejects duplicate fake streaming models", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + expect(() => + UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gpt-image-2", groupTags: ["group-a"] }, + ], + }) + ).toThrow(); + }); + + test("rejects duplicate models after trimming", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + expect(() => + UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [ + { model: "gpt-image-2", groupTags: [] }, + { model: " gpt-image-2 ", groupTags: [] }, + ], + }) + ).toThrow(); + }); + + test("trims model and groupTags entries", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const parsed = UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [ + { model: " gpt-image-2 ", groupTags: [" group-a ", " group-b "] }, + ], + }); + + expect(parsed.fakeStreamingWhitelist).toEqual([ + { model: "gpt-image-2", groupTags: ["group-a", "group-b"] }, + ]); + }); + + test("accepts empty groupTags as all-groups semantic", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const parsed = UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [{ model: "gpt-image-2", groupTags: [] }], + }); + + expect(parsed.fakeStreamingWhitelist).toEqual([{ model: "gpt-image-2", groupTags: [] }]); + }); + + test("accepts empty whitelist (explicit disable)", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const parsed = UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [], + }); + + expect(parsed.fakeStreamingWhitelist).toEqual([]); + }); + + test("rejects empty model string", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + expect(() => + UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [{ model: "", groupTags: [] }], + }) + ).toThrow(); + + expect(() => + UpdateSystemSettingsSchema.parse({ + fakeStreamingWhitelist: [{ model: " ", groupTags: [] }], + }) + ).toThrow(); + }); + }); + + describe("save action", () => { + test("saves fake streaming whitelist entry for all groups and invalidates cache", async () => { + const persisted = [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gpt-image-1.5", groupTags: ["group-a", "group-b"] }, + ]; + + updateSystemSettingsMock.mockResolvedValueOnce( + createSettings({ fakeStreamingWhitelist: persisted }) + ); + + const { saveSystemSettings } = await import("@/actions/system-config"); + const result = await saveSystemSettings({ + fakeStreamingWhitelist: persisted, + }); + + expect(result.ok).toBe(true); + expect(updateSystemSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + fakeStreamingWhitelist: persisted, + }) + ); + expect(invalidateSystemSettingsCacheMock).toHaveBeenCalledTimes(1); + if (result.ok) { + expect(result.data.fakeStreamingWhitelist).toEqual(persisted); + } + }); + + test("preserves empty fake streaming whitelist as explicit opt out", async () => { + updateSystemSettingsMock.mockResolvedValueOnce( + createSettings({ fakeStreamingWhitelist: [] }) + ); + + const { saveSystemSettings } = await import("@/actions/system-config"); + const result = await saveSystemSettings({ + fakeStreamingWhitelist: [], + }); + + expect(result.ok).toBe(true); + expect(updateSystemSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + fakeStreamingWhitelist: [], + }) + ); + if (result.ok) { + expect(result.data.fakeStreamingWhitelist).toEqual([]); + } + }); + }); +}); diff --git a/tests/unit/proxy/fake-streaming-eligibility.test.ts b/tests/unit/proxy/fake-streaming-eligibility.test.ts new file mode 100644 index 000000000..b69676256 --- /dev/null +++ b/tests/unit/proxy/fake-streaming-eligibility.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import { isFakeStreamingEligible } from "@/app/v1/_lib/proxy/fake-streaming/eligibility"; +import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; +import type { FakeStreamingWhitelistEntry } from "@/types/system-config"; + +describe("isFakeStreamingEligible", () => { + test("matches exact model for all groups when groupTags is empty", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [{ model: "gpt-image-2", groupTags: [] }]; + + expect(isFakeStreamingEligible("gpt-image-2", "any-group", whitelist)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", "default", whitelist)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", null, whitelist)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", undefined, whitelist)).toBe(true); + }); + + test("rejects model not in whitelist", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [{ model: "gpt-image-2", groupTags: [] }]; + + expect(isFakeStreamingEligible("claude-3-5-sonnet-latest", "default", whitelist)).toBe(false); + expect(isFakeStreamingEligible("gpt-image", "default", whitelist)).toBe(false); + expect(isFakeStreamingEligible("gpt-image-2-turbo", "default", whitelist)).toBe(false); + }); + + test("does not match by prefix or substring", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [{ model: "claude-3", groupTags: [] }]; + + expect(isFakeStreamingEligible("claude-3", "default", whitelist)).toBe(true); + expect(isFakeStreamingEligible("claude-3-5-sonnet-latest", "default", whitelist)).toBe(false); + expect(isFakeStreamingEligible("anthropic/claude-3", "default", whitelist)).toBe(false); + }); + + test("matches only configured provider groups when groupTags is non-empty", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [ + { model: "gpt-image-2", groupTags: ["group-a", "group-b"] }, + ]; + + expect(isFakeStreamingEligible("gpt-image-2", "group-a", whitelist)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", "group-b", whitelist)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", "group-c", whitelist)).toBe(false); + }); + + test("missing group resolves via default group constant", () => { + const whitelistAll: FakeStreamingWhitelistEntry[] = [{ model: "gpt-image-2", groupTags: [] }]; + expect(isFakeStreamingEligible("gpt-image-2", null, whitelistAll)).toBe(true); + + const whitelistDefault: FakeStreamingWhitelistEntry[] = [ + { model: "gpt-image-2", groupTags: [PROVIDER_GROUP.DEFAULT] }, + ]; + expect(isFakeStreamingEligible("gpt-image-2", null, whitelistDefault)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", undefined, whitelistDefault)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", "", whitelistDefault)).toBe(true); + + const whitelistOther: FakeStreamingWhitelistEntry[] = [ + { model: "gpt-image-2", groupTags: ["group-a"] }, + ]; + expect(isFakeStreamingEligible("gpt-image-2", null, whitelistOther)).toBe(false); + }); + + test("returns false when whitelist is empty (explicit opt out)", () => { + expect(isFakeStreamingEligible("gpt-image-2", "default", [])).toBe(false); + expect(isFakeStreamingEligible("any-model", null, [])).toBe(false); + }); + + test("trims whitespace from inputs and whitelist values", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [ + { model: "gpt-image-2", groupTags: ["group-a"] }, + ]; + + expect(isFakeStreamingEligible(" gpt-image-2 ", " group-a ", whitelist)).toBe(true); + expect(isFakeStreamingEligible("gpt-image-2", " group-a ", whitelist)).toBe(true); + }); + + test("rejects empty model string", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [{ model: "gpt-image-2", groupTags: [] }]; + + expect(isFakeStreamingEligible("", "default", whitelist)).toBe(false); + expect(isFakeStreamingEligible(" ", "default", whitelist)).toBe(false); + }); + + test("default image-generation models match when whitelist contains them with empty groups", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gpt-image-1.5", groupTags: [] }, + { model: "gemini-3.1-flash-image-preview", groupTags: [] }, + { model: "gemini-3-pro-image-preview", groupTags: [] }, + ]; + + for (const model of [ + "gpt-image-2", + "gpt-image-1.5", + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image-preview", + ]) { + expect(isFakeStreamingEligible(model, "default", whitelist)).toBe(true); + expect(isFakeStreamingEligible(model, "any-group", whitelist)).toBe(true); + } + }); + + test("ignores duplicate model entries (deterministic first match)", () => { + const whitelist: FakeStreamingWhitelistEntry[] = [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gpt-image-2", groupTags: ["group-x"] }, + ]; + + // Even if a duplicate slipped through (validation should prevent), the first + // entry's "all groups" semantics should win, so any group matches. + expect(isFakeStreamingEligible("gpt-image-2", "group-y", whitelist)).toBe(true); + }); +}); diff --git a/tests/unit/proxy/fake-streaming-orchestrator.test.ts b/tests/unit/proxy/fake-streaming-orchestrator.test.ts new file mode 100644 index 000000000..37537b668 --- /dev/null +++ b/tests/unit/proxy/fake-streaming-orchestrator.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + orchestrateFakeStreamingAttempts, + type FakeStreamingAttemptOutcome, +} from "@/app/v1/_lib/proxy/fake-streaming/orchestrator"; + +const validBody = JSON.stringify({ + id: "msg", + type: "message", + content: [{ type: "text", text: "ok" }], +}); + +const emptyBody = ""; + +function makeAttempt(outcome: { + status: number; + body: string; + providerId: string; +}): FakeStreamingAttemptOutcome { + return outcome; +} + +describe("orchestrateFakeStreamingAttempts", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("returns success on first valid attempt", async () => { + const performAttempt = vi.fn(async (_index: number) => + makeAttempt({ status: 200, body: validBody, providerId: "p1" }) + ); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + }); + + expect(result.ok).toBe(true); + expect(result.finalBody).toBe(validBody); + expect(result.attempts).toHaveLength(1); + expect(result.attempts[0].providerId).toBe("p1"); + expect(performAttempt).toHaveBeenCalledTimes(1); + }); + + test("never runs more than one upstream attempt at once (no race)", async () => { + let inFlight = 0; + let maxInFlight = 0; + const performAttempt = vi.fn(async (index: number) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + try { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (index === 0) { + return makeAttempt({ status: 200, body: emptyBody, providerId: "p1" }); + } + return makeAttempt({ status: 200, body: validBody, providerId: "p2" }); + } finally { + inFlight -= 1; + } + }); + + const promise = orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + }); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.ok).toBe(true); + expect(result.attempts).toHaveLength(2); + expect(result.attempts[0].validation.ok).toBe(false); + expect(result.attempts[1].validation.ok).toBe(true); + expect(maxInFlight).toBe(1); + expect(performAttempt).toHaveBeenCalledTimes(2); + }); + + test("retries on empty upstream until success or providers exhaust", async () => { + const performAttempt = vi.fn( + async (index: number): Promise => { + if (index === 0) return makeAttempt({ status: 200, body: emptyBody, providerId: "p1" }); + if (index === 1) return makeAttempt({ status: 200, body: " ", providerId: "p2" }); + if (index === 2) return makeAttempt({ status: 200, body: validBody, providerId: "p3" }); + return null; + } + ); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + }); + + expect(result.ok).toBe(true); + expect(result.attempts).toHaveLength(3); + expect(result.finalBody).toBe(validBody); + }); + + test("returns failure when all providers fail", async () => { + let calls = 0; + const performAttempt = vi.fn(async (): Promise => { + calls += 1; + if (calls > 3) return null; + return makeAttempt({ status: 200, body: emptyBody, providerId: `p${calls}` }); + }); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 10, + }); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe("upstream_all_attempts_failed"); + expect(result.attempts).toHaveLength(3); + expect(result.attempts.every((a) => !a.validation.ok)).toBe(true); + }); + + test("aborts current attempt and stops fallback on client disconnect", async () => { + const abortController = new AbortController(); + const seenAborts: AbortSignal[] = []; + + const performAttempt = vi.fn(async (index: number, signal: AbortSignal) => { + seenAborts.push(signal); + if (index === 0) { + return await new Promise((_, reject) => { + signal.addEventListener("abort", () => { + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + }); + } + return makeAttempt({ status: 200, body: validBody, providerId: "p2" }); + }); + + const promise = orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: abortController.signal, + maxAttempts: 5, + }); + + // Abort while first attempt is pending + await Promise.resolve(); + abortController.abort(); + + const result = await promise; + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe("client_abort"); + expect(performAttempt).toHaveBeenCalledTimes(1); + expect(seenAborts.length).toBe(1); + expect(seenAborts[0].aborted).toBe(true); + }); + + test("maxAttempts caps the loop even if more providers are available", async () => { + const performAttempt = vi.fn(async (index: number) => + makeAttempt({ status: 200, body: emptyBody, providerId: `p${index}` }) + ); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 2, + }); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe("upstream_all_attempts_failed"); + expect(result.attempts).toHaveLength(2); + expect(performAttempt).toHaveBeenCalledTimes(2); + }); + + test("preserves attempt metadata across attempts", async () => { + const performAttempt = vi.fn(async (index: number) => { + if (index === 0) { + return makeAttempt({ status: 502, body: "bad gateway", providerId: "p1" }); + } + return makeAttempt({ status: 200, body: validBody, providerId: "p2" }); + }); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + }); + + expect(result.attempts[0].providerId).toBe("p1"); + expect(result.attempts[0].status).toBe(502); + expect(result.attempts[0].validation.ok).toBe(false); + expect(result.attempts[0].validation.code).toBe("non_2xx_status"); + + expect(result.attempts[1].providerId).toBe("p2"); + expect(result.attempts[1].status).toBe(200); + expect(result.attempts[1].validation.ok).toBe(true); + }); + + test("returns no_providers when first call returns null", async () => { + const performAttempt = vi.fn(async () => null); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + }); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe("no_providers"); + expect(result.attempts).toHaveLength(0); + }); + + test("client abort signal flows into in-flight perform attempts", async () => { + const abortController = new AbortController(); + let observedSignalDuringAttempt: AbortSignal | null = null; + const performAttempt = vi.fn(async (_index: number, signal: AbortSignal) => { + observedSignalDuringAttempt = signal; + // While the attempt is in flight, parent abort should propagate. + await Promise.resolve(); + abortController.abort(); + expect(signal.aborted).toBe(true); + throw Object.assign(new Error("aborted"), { name: "AbortError" }); + }); + + const result = await orchestrateFakeStreamingAttempts({ + family: "anthropic", + performAttempt, + abortSignal: abortController.signal, + maxAttempts: 5, + }); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe("client_abort"); + expect(observedSignalDuringAttempt).not.toBeNull(); + }); +}); diff --git a/tests/unit/proxy/fake-streaming-response-validator.test.ts b/tests/unit/proxy/fake-streaming-response-validator.test.ts new file mode 100644 index 000000000..83b33deba --- /dev/null +++ b/tests/unit/proxy/fake-streaming-response-validator.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from "vitest"; +import { + validateUpstreamResponse, + type ProtocolFamily, +} from "@/app/v1/_lib/proxy/fake-streaming/response-validator"; + +function failure(family: ProtocolFamily, body: string, isStream: boolean, status = 200) { + return validateUpstreamResponse({ + family, + status, + body, + isStream, + }); +} + +describe("validateUpstreamResponse", () => { + describe("status code handling", () => { + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: non-2xx is failure regardless of body", (family) => { + const valid = `{"id":"ok","model":"m","content":[{"type":"text","text":"hi"}]}`; + expect(failure(family, valid, false, 500).ok).toBe(false); + expect(failure(family, valid, false, 502).ok).toBe(false); + expect(failure(family, valid, false, 429).ok).toBe(false); + expect(failure(family, valid, false, 401).ok).toBe(false); + }); + }); + + describe("empty / whitespace bodies", () => { + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: empty body fails (non-stream)", (family) => { + expect(failure(family, "", false).ok).toBe(false); + expect(failure(family, " ", false).ok).toBe(false); + expect(failure(family, "\n\n \t\n", false).ok).toBe(false); + }); + + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: empty body fails (stream)", (family) => { + expect(failure(family, "", true).ok).toBe(false); + expect(failure(family, " ", true).ok).toBe(false); + }); + }); + + describe("invalid JSON for non-stream", () => { + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: invalid JSON fails non-stream", (family) => { + expect(failure(family, "not-json", false).ok).toBe(false); + expect(failure(family, "{ truncated", false).ok).toBe(false); + }); + }); + + describe("SSE failure cases", () => { + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: comment-only SSE fails", (family) => { + expect(failure(family, ": ping\n\n: ping\n\n", true).ok).toBe(false); + }); + + test("openai-chat: [DONE]-only SSE fails", () => { + expect(failure("openai-chat", "data: [DONE]\n\n", true).ok).toBe(false); + }); + + test("openai-chat: error SSE fails", () => { + const errEvent = `event: error\ndata: {"error":{"message":"upstream"}}\n\n`; + expect(failure("openai-chat", errEvent, true).ok).toBe(false); + }); + + test("anthropic: error SSE fails", () => { + const errEvent = `event: error\ndata: {"type":"error","error":{"type":"overloaded_error"}}\n\n`; + expect(failure("anthropic", errEvent, true).ok).toBe(false); + }); + + test("openai-chat: usage-only chunk fails (no delta content / tool_calls)", () => { + const usageOnly = `data: {"id":"x","object":"chat.completion.chunk","choices":[{"delta":{},"index":0}],"usage":{"completion_tokens":3}}\n\ndata: [DONE]\n\n`; + expect(failure("openai-chat", usageOnly, true).ok).toBe(false); + }); + }); + + describe("non-stream success cases", () => { + test("anthropic: text content_block accepted", () => { + const body = JSON.stringify({ + id: "msg", + type: "message", + role: "assistant", + model: "claude-3", + content: [{ type: "text", text: "hello" }], + }); + expect(failure("anthropic", body, false).ok).toBe(true); + }); + + test("anthropic: tool_use block accepted (no text)", () => { + const body = JSON.stringify({ + id: "msg", + type: "message", + role: "assistant", + model: "claude-3", + content: [{ type: "tool_use", id: "tu_1", name: "foo", input: { x: 1 } }], + }); + expect(failure("anthropic", body, false).ok).toBe(true); + }); + + test("openai-chat: text choice accepted", () => { + const body = JSON.stringify({ + id: "x", + object: "chat.completion", + choices: [{ message: { content: "hi" }, index: 0, finish_reason: "stop" }], + }); + expect(failure("openai-chat", body, false).ok).toBe(true); + }); + + test("openai-chat: function_call accepted (no text)", () => { + const body = JSON.stringify({ + id: "x", + object: "chat.completion", + choices: [ + { + message: { + tool_calls: [{ id: "t", type: "function", function: { name: "f", arguments: "{}" } }], + }, + index: 0, + finish_reason: "tool_calls", + }, + ], + }); + expect(failure("openai-chat", body, false).ok).toBe(true); + }); + + test("openai-responses: text output_item accepted", () => { + const body = JSON.stringify({ + id: "resp_1", + object: "response", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hi" }], + }, + ], + }); + expect(failure("openai-responses", body, false).ok).toBe(true); + }); + + test("openai-responses: structured output accepted", () => { + const body = JSON.stringify({ + id: "resp_1", + object: "response", + output: [ + { + type: "function_call", + name: "foo", + arguments: "{}", + }, + ], + }); + expect(failure("openai-responses", body, false).ok).toBe(true); + }); + + test("gemini: candidates accepted", () => { + const body = JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: "hi" }] }, + finishReason: "STOP", + }, + ], + }); + expect(failure("gemini", body, false).ok).toBe(true); + }); + + test("gemini: candidates with image bytes only also accepted (no text)", () => { + const body = JSON.stringify({ + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/png", data: "AAAA" } }], + }, + finishReason: "STOP", + }, + ], + }); + expect(failure("gemini", body, false).ok).toBe(true); + }); + }); + + describe("non-stream failure cases", () => { + test("anthropic: empty content array fails", () => { + const body = JSON.stringify({ + id: "msg", + type: "message", + content: [], + }); + expect(failure("anthropic", body, false).ok).toBe(false); + }); + + test("openai-chat: empty choices array fails", () => { + const body = JSON.stringify({ + id: "x", + object: "chat.completion", + choices: [], + }); + expect(failure("openai-chat", body, false).ok).toBe(false); + }); + + test("openai-chat: choice with empty message content + no tool_calls fails", () => { + const body = JSON.stringify({ + id: "x", + object: "chat.completion", + choices: [{ message: { content: "" }, index: 0, finish_reason: "stop" }], + }); + expect(failure("openai-chat", body, false).ok).toBe(false); + }); + + test("openai-responses: empty output array fails", () => { + const body = JSON.stringify({ + id: "resp_1", + object: "response", + output: [], + }); + expect(failure("openai-responses", body, false).ok).toBe(false); + }); + + test("gemini: empty candidates array fails", () => { + const body = JSON.stringify({ candidates: [] }); + expect(failure("gemini", body, false).ok).toBe(false); + }); + + test("gemini: candidate with no content parts fails", () => { + const body = JSON.stringify({ + candidates: [{ finishReason: "STOP" }], + }); + expect(failure("gemini", body, false).ok).toBe(false); + }); + }); + + describe("stream success cases", () => { + test("anthropic: message_start + content_block_delta accepted", () => { + const sse = + `event: message_start\ndata: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"claude-3","content":[]}}\n\n` + + `event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n` + + `event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}\n\n` + + `event: message_stop\ndata: {"type":"message_stop"}\n\n`; + expect(failure("anthropic", sse, true).ok).toBe(true); + }); + + test("openai-chat: chunks with delta content + [DONE] accepted", () => { + const sse = + `data: {"id":"x","object":"chat.completion.chunk","choices":[{"delta":{"content":"hi"},"index":0}]}\n\n` + + `data: [DONE]\n\n`; + expect(failure("openai-chat", sse, true).ok).toBe(true); + }); + + test("openai-chat: tool_calls delta accepted", () => { + const sse = + `data: {"id":"x","object":"chat.completion.chunk","choices":[{"delta":{"tool_calls":[{"index":0,"id":"t","type":"function","function":{"name":"f","arguments":"{}"}}]},"index":0}]}\n\n` + + `data: [DONE]\n\n`; + expect(failure("openai-chat", sse, true).ok).toBe(true); + }); + + test("openai-responses: response.created + completed accepted", () => { + const sse = + `event: response.created\ndata: {"type":"response.created","response":{"id":"resp_1","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}]}}\n\n` + + `event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_1","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}]}}\n\n`; + expect(failure("openai-responses", sse, true).ok).toBe(true); + }); + + test("gemini: data event with candidates accepted", () => { + const sse = `data: ${JSON.stringify({ + candidates: [{ content: { parts: [{ text: "hi" }] }, finishReason: "STOP" }], + })}\n\n`; + expect(failure("gemini", sse, true).ok).toBe(true); + }); + }); +}); diff --git a/tests/unit/proxy/fake-streaming-response.test.ts b/tests/unit/proxy/fake-streaming-response.test.ts new file mode 100644 index 000000000..fa8342b74 --- /dev/null +++ b/tests/unit/proxy/fake-streaming-response.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from "vitest"; +import { + emitFinalNonStream, + emitFinalStream, + emitStreamError, +} from "@/app/v1/_lib/proxy/fake-streaming/emitters"; +import type { ProtocolFamily } from "@/app/v1/_lib/proxy/fake-streaming/response-validator"; + +function parseSseEvents(body: string): Array<{ event: string | null; data: string }> { + const events: Array<{ event: string | null; data: string }> = []; + const dataLines: string[] = []; + let currentEvent: string | null = null; + + const flush = () => { + if (dataLines.length === 0) { + currentEvent = null; + return; + } + events.push({ event: currentEvent, data: dataLines.join("\n") }); + dataLines.length = 0; + currentEvent = null; + }; + + for (const line of body.split(/\r?\n/)) { + if (line.length === 0) { + flush(); + continue; + } + if (line.startsWith(":")) continue; + if (line.startsWith("event:")) { + currentEvent = line.slice(6).trim(); + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).replace(/^\s/, "")); + } + } + flush(); + return events; +} + +describe("emitFinalNonStream", () => { + test.each([ + "anthropic", + "openai-chat", + "openai-responses", + "gemini", + ])("%s: returns the validated final body verbatim", (family) => { + const body = JSON.stringify({ id: "x", model: "m", content: [{ type: "text", text: "hi" }] }); + expect(emitFinalNonStream({ family, finalBody: body })).toBe(body); + }); +}); + +describe("emitFinalStream — anthropic", () => { + test("emits message_start, content_block_*, message_delta, message_stop for text content", () => { + const finalBody = JSON.stringify({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-3-5-sonnet-latest", + content: [{ type: "text", text: "hello world" }], + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 5 }, + }); + + const sse = emitFinalStream({ family: "anthropic", finalBody }); + const events = parseSseEvents(sse); + const eventNames = events.map((e) => e.event).filter((name) => name !== null); + + expect(eventNames[0]).toBe("message_start"); + expect(eventNames).toContain("content_block_start"); + expect(eventNames).toContain("content_block_delta"); + expect(eventNames).toContain("content_block_stop"); + expect(eventNames).toContain("message_delta"); + expect(eventNames[eventNames.length - 1]).toBe("message_stop"); + + // Find the text delta and verify it contains the full text + const textDelta = events.find((e) => e.event === "content_block_delta"); + expect(textDelta).toBeTruthy(); + if (textDelta) { + const parsed = JSON.parse(textDelta.data); + expect(parsed.delta.text).toBe("hello world"); + } + }); + + test("emits content_block_start with full block for non-text content (tool_use)", () => { + const finalBody = JSON.stringify({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude-3-5", + content: [ + { + type: "tool_use", + id: "tu_1", + name: "search", + input: { query: "x" }, + }, + ], + stop_reason: "tool_use", + usage: { input_tokens: 10, output_tokens: 5 }, + }); + + const sse = emitFinalStream({ family: "anthropic", finalBody }); + const events = parseSseEvents(sse); + const blockStart = events.find((e) => e.event === "content_block_start"); + expect(blockStart).toBeTruthy(); + if (blockStart) { + const parsed = JSON.parse(blockStart.data); + expect(parsed.content_block.type).toBe("tool_use"); + expect(parsed.content_block.input).toEqual({ query: "x" }); + } + + // No text delta for tool_use + const textDeltas = events.filter( + (e) => e.event === "content_block_delta" && JSON.parse(e.data).delta?.type === "text_delta" + ); + expect(textDeltas.length).toBe(0); + }); +}); + +describe("emitFinalStream — openai-chat", () => { + test("emits chat.completion.chunk delta, finish_reason, [DONE]", () => { + const finalBody = JSON.stringify({ + id: "chatcmpl_1", + object: "chat.completion", + created: 1700000000, + model: "gpt-4o-mini", + choices: [ + { + message: { role: "assistant", content: "hi there" }, + index: 0, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }); + + const sse = emitFinalStream({ family: "openai-chat", finalBody }); + const events = parseSseEvents(sse); + + expect(events.length).toBeGreaterThan(0); + + // First event must be a chunk with role + const firstChunk = JSON.parse(events[0].data); + expect(firstChunk.object).toBe("chat.completion.chunk"); + expect(firstChunk.choices[0].delta.role).toBe("assistant"); + + // There must be a chunk with delta.content carrying the text + const textChunkEvents = events + .filter((e) => e.data !== "[DONE]") + .map((e) => JSON.parse(e.data)); + const textJoined = textChunkEvents + .flatMap( + (c) => + c.choices?.flatMap((ch: { delta?: { content?: string } }) => ch.delta?.content ?? "") ?? + [] + ) + .join(""); + expect(textJoined).toContain("hi there"); + + // Finish reason chunk + const finishChunk = textChunkEvents.find((c) => + c.choices?.some((ch: { finish_reason?: string }) => ch.finish_reason === "stop") + ); + expect(finishChunk).toBeTruthy(); + + // Final [DONE] + expect(events[events.length - 1].data).toBe("[DONE]"); + }); +}); + +describe("emitFinalStream — openai-responses", () => { + test("emits response.created and response.completed events", () => { + const finalBody = JSON.stringify({ + id: "resp_1", + object: "response", + created: 1700000000, + model: "gpt-4o-mini", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hi" }], + }, + ], + }); + + const sse = emitFinalStream({ family: "openai-responses", finalBody }); + const events = parseSseEvents(sse); + const eventNames = events.map((e) => e.event).filter((name) => name !== null); + + expect(eventNames[0]).toBe("response.created"); + expect(eventNames).toContain("response.completed"); + + const completed = events.find((e) => e.event === "response.completed"); + expect(completed).toBeTruthy(); + if (completed) { + const parsed = JSON.parse(completed.data); + expect(parsed.response.output[0].content[0].text).toBe("hi"); + } + }); +}); + +describe("emitFinalStream — gemini", () => { + test("emits a single data frame containing the full candidates body", () => { + const finalObj = { + candidates: [ + { + content: { parts: [{ text: "hi" }] }, + finishReason: "STOP", + index: 0, + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, + modelVersion: "gemini-3-pro", + }; + const sse = emitFinalStream({ family: "gemini", finalBody: JSON.stringify(finalObj) }); + const events = parseSseEvents(sse); + + expect(events.length).toBe(1); + const parsed = JSON.parse(events[0].data); + expect(parsed).toEqual(finalObj); + }); +}); + +describe("emitStreamError", () => { + test("anthropic emits event: error with type=error", () => { + const sse = emitStreamError({ + family: "anthropic", + errorMessage: "all upstream attempts failed", + errorCode: "upstream_all_attempts_failed", + }); + const events = parseSseEvents(sse); + const errEvent = events.find((e) => e.event === "error"); + expect(errEvent).toBeTruthy(); + if (errEvent) { + const parsed = JSON.parse(errEvent.data); + expect(parsed.type).toBe("error"); + expect(parsed.error.type).toBe("upstream_all_attempts_failed"); + } + + // Must NOT include any success terminator + expect(events.some((e) => e.event === "message_stop")).toBe(false); + }); + + test("openai-chat emits a JSON error frame and no [DONE] success terminator", () => { + const sse = emitStreamError({ + family: "openai-chat", + errorMessage: "all upstream attempts failed", + errorCode: "upstream_all_attempts_failed", + }); + const events = parseSseEvents(sse); + + expect(events.length).toBeGreaterThan(0); + const last = events[events.length - 1]; + expect(last.data).not.toBe("[DONE]"); + + const errPayload = JSON.parse(events[0].data); + expect(errPayload.error).toBeTruthy(); + expect(errPayload.error.code ?? errPayload.error.type).toBe("upstream_all_attempts_failed"); + }); + + test("openai-responses emits response.error and no response.completed", () => { + const sse = emitStreamError({ + family: "openai-responses", + errorMessage: "all upstream attempts failed", + errorCode: "upstream_all_attempts_failed", + }); + const events = parseSseEvents(sse); + const errEvent = events.find((e) => e.event === "response.error"); + expect(errEvent).toBeTruthy(); + expect(events.some((e) => e.event === "response.completed")).toBe(false); + }); + + test("gemini emits an error data frame", () => { + const sse = emitStreamError({ + family: "gemini", + errorMessage: "all upstream attempts failed", + errorCode: "upstream_all_attempts_failed", + }); + const events = parseSseEvents(sse); + expect(events.length).toBe(1); + const parsed = JSON.parse(events[0].data); + expect(parsed.error).toBeTruthy(); + }); +}); diff --git a/tests/unit/proxy/fake-streaming-stream-intent.test.ts b/tests/unit/proxy/fake-streaming-stream-intent.test.ts new file mode 100644 index 000000000..4ac2466a8 --- /dev/null +++ b/tests/unit/proxy/fake-streaming-stream-intent.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "vitest"; +import { + cloneRequestForInternalNonStreamAttempt, + detectClientStreamIntent, +} from "@/app/v1/_lib/proxy/fake-streaming/stream-intent"; +import type { ClientFormat } from "@/app/v1/_lib/proxy/format-mapper"; + +function inputs({ + format, + pathname, + search, + body, +}: { + format: ClientFormat; + pathname: string; + search?: string; + body?: Record | null; +}) { + return { + format, + pathname, + search: search ?? "", + body: body ?? null, + }; +} + +describe("detectClientStreamIntent", () => { + describe("standard formats (claude / openai / response)", () => { + test.each([ + "claude", + "openai", + "response", + ])("%s: body.stream === true => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ format, pathname: "/v1/messages", body: { stream: true } }) + ) + ).toBe(true); + }); + + test.each([ + "claude", + "openai", + "response", + ])("%s: body.stream missing or false => non-stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ format, pathname: "/v1/messages", body: { stream: false } }) + ) + ).toBe(false); + expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages", body: {} }))).toBe( + false + ); + expect(detectClientStreamIntent(inputs({ format, pathname: "/v1/messages" }))).toBe(false); + }); + + test("standard formats ignore path / query for stream intent", () => { + expect( + detectClientStreamIntent( + inputs({ + format: "openai", + pathname: "/v1/chat/completions", + search: "?alt=sse", + body: { stream: false }, + }) + ) + ).toBe(false); + }); + }); + + describe("gemini family", () => { + test.each([ + "gemini", + "gemini-cli", + ])("%s: streamGenerateContent in path => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", + body: {}, + }) + ) + ).toBe(true); + }); + + test.each(["gemini", "gemini-cli"])("%s: alt=sse query => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + search: "?alt=sse", + body: {}, + }) + ) + ).toBe(true); + }); + + test.each([ + "gemini", + "gemini-cli", + ])("%s: body.stream === true => stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + body: { stream: true }, + }) + ) + ).toBe(true); + }); + + test.each([ + "gemini", + "gemini-cli", + ])("%s: no streaming signal => non-stream", (format) => { + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + body: { stream: false }, + }) + ) + ).toBe(false); + expect( + detectClientStreamIntent( + inputs({ + format, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + }) + ) + ).toBe(false); + }); + + test("gemini search supports object form", () => { + expect( + detectClientStreamIntent( + inputs({ + format: "gemini", + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + search: "?alt=json", + body: {}, + }) + ) + ).toBe(false); + }); + }); +}); + +describe("cloneRequestForInternalNonStreamAttempt", () => { + test("standard format clones body with stream: false without mutating original", () => { + const original = { + format: "openai" as ClientFormat, + pathname: "/v1/chat/completions", + search: "", + body: { stream: true, model: "gpt-4o-mini", messages: [] } as Record, + }; + + const clone = cloneRequestForInternalNonStreamAttempt(original); + + expect(clone.pathname).toBe("/v1/chat/completions"); + expect(clone.search).toBe(""); + expect(clone.body).toEqual({ stream: false, model: "gpt-4o-mini", messages: [] }); + // Original must not have been mutated + expect(original.body).toEqual({ stream: true, model: "gpt-4o-mini", messages: [] }); + expect(clone.body).not.toBe(original.body); + }); + + test("standard format adds stream: false even when missing", () => { + const original = { + format: "claude" as ClientFormat, + pathname: "/v1/messages", + search: "", + body: { model: "claude-3-5", messages: [] } as Record, + }; + + const clone = cloneRequestForInternalNonStreamAttempt(original); + expect(clone.body).toEqual({ model: "claude-3-5", messages: [], stream: false }); + }); + + test("gemini path rewrites streamGenerateContent to generateContent", () => { + const original = { + format: "gemini" as ClientFormat, + pathname: "/v1beta/models/gemini-3-pro-image-preview:streamGenerateContent", + search: "?alt=sse&key=abc", + body: { contents: [] } as Record, + }; + + const clone = cloneRequestForInternalNonStreamAttempt(original); + expect(clone.pathname).toBe("/v1beta/models/gemini-3-pro-image-preview:generateContent"); + expect(clone.search.includes("alt=sse")).toBe(false); + expect(clone.search.includes("key=abc")).toBe(true); + // Original is not mutated + expect(original.pathname).toBe( + "/v1beta/models/gemini-3-pro-image-preview:streamGenerateContent" + ); + expect(original.search).toBe("?alt=sse&key=abc"); + }); + + test("gemini drops alt=sse but keeps other query params", () => { + const original = { + format: "gemini-cli" as ClientFormat, + pathname: "/v1internal/models/gemini-3-pro-image-preview:generateContent", + search: "?alt=sse&clientName=cli", + body: {} as Record, + }; + + const clone = cloneRequestForInternalNonStreamAttempt(original); + expect(clone.pathname).toBe("/v1internal/models/gemini-3-pro-image-preview:generateContent"); + expect(clone.search).toBe("?clientName=cli"); + }); + + test("gemini sets body.stream=false when present", () => { + const original = { + format: "gemini" as ClientFormat, + pathname: "/v1beta/models/gemini-1.5-pro:generateContent", + search: "", + body: { stream: true, contents: [] } as Record, + }; + + const clone = cloneRequestForInternalNonStreamAttempt(original); + expect(clone.body).toEqual({ stream: false, contents: [] }); + expect(original.body).toEqual({ stream: true, contents: [] }); + }); + + test("preserves null body for gemini without body", () => { + const original = { + format: "gemini" as ClientFormat, + pathname: "/v1beta/models/gemini-1.5-pro:streamGenerateContent", + search: "?alt=sse", + body: null as Record | null, + }; + + const clone = cloneRequestForInternalNonStreamAttempt(original); + expect(clone.body).toBeNull(); + expect(clone.pathname).toBe("/v1beta/models/gemini-1.5-pro:generateContent"); + expect(clone.search).toBe(""); + }); +}); diff --git a/tests/unit/proxy/response-handler-fake-streaming.test.ts b/tests/unit/proxy/response-handler-fake-streaming.test.ts new file mode 100644 index 000000000..4b70b7178 --- /dev/null +++ b/tests/unit/proxy/response-handler-fake-streaming.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + buildFakeStreamingNonStreamResponse, + buildFakeStreamingResponse, + type AttemptPerformer, +} from "@/app/v1/_lib/proxy/fake-streaming/runner"; + +const validBody = JSON.stringify({ + id: "msg", + type: "message", + content: [{ type: "text", text: "hello" }], + model: "claude-3", +}); + +const emptyBody = ""; + +async function consumeStream(stream: ReadableStream | null): Promise { + if (!stream) return ""; + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let result = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + result += decoder.decode(); + return result; +} + +async function readUntilFirstChunk( + stream: ReadableStream | null +): Promise<{ text: string; reader: ReadableStreamDefaultReader }> { + if (!stream) throw new Error("stream is null"); + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const { value } = await reader.read(); + return { + text: value ? decoder.decode(value, { stream: true }) : "", + reader, + }; +} + +describe("buildFakeStreamingResponse — stream path", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("emits SSE heartbeat immediately and final emission after success", async () => { + const performAttempt = vi.fn(async () => ({ + status: 200, + body: validBody, + providerId: "p1", + })); + + const response = buildFakeStreamingResponse({ + family: "anthropic", + isStream: true, + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + heartbeatIntervalMs: 5000, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + + await vi.runAllTimersAsync(); + const body = await consumeStream(response.body); + + expect(body.startsWith(": ping\n\n")).toBe(true); + expect(body).toContain("event: message_start"); + expect(body).toContain("event: message_stop"); + expect(performAttempt).toHaveBeenCalledTimes(1); + }); + + test("retries on empty upstream and only emits provider B final data", async () => { + const performAttempt = vi.fn(async (index: number) => { + if (index === 0) return { status: 200, body: emptyBody, providerId: "p1" }; + return { status: 200, body: validBody, providerId: "p2" }; + }); + + const response = buildFakeStreamingResponse({ + family: "anthropic", + isStream: true, + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + heartbeatIntervalMs: 5000, + }); + + await vi.runAllTimersAsync(); + const body = await consumeStream(response.body); + + expect(body).toContain("event: message_start"); + expect(body).toContain("event: message_stop"); + // Provider A's empty body must not leak into the stream + expect(body).not.toContain("p1-data"); + expect(performAttempt).toHaveBeenCalledTimes(2); + }); + + test("emits protocol-compatible error on terminal failure (no success terminator)", async () => { + const performAttempt = vi.fn( + async ( + index: number + ): Promise<{ status: number; body: string; providerId: string } | null> => { + if (index < 3) return { status: 200, body: emptyBody, providerId: `p${index}` }; + return null; + } + ); + + const response = buildFakeStreamingResponse({ + family: "anthropic", + isStream: true, + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + heartbeatIntervalMs: 5000, + }); + + await vi.runAllTimersAsync(); + const body = await consumeStream(response.body); + + expect(body).toContain("event: error"); + expect(body).not.toContain("event: message_stop"); + }); + + test("repeats heartbeat at the configured interval while attempts pend", async () => { + let releaseAttempt: (() => void) | null = null; + const performAttempt = vi.fn( + async () => + new Promise<{ status: number; body: string; providerId: string }>((resolve) => { + releaseAttempt = () => resolve({ status: 200, body: validBody, providerId: "p1" }); + }) + ); + + const response = buildFakeStreamingResponse({ + family: "anthropic", + isStream: true, + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 1, + heartbeatIntervalMs: 5000, + }); + + expect(response.body).not.toBeNull(); + if (!response.body) throw new Error("body must not be null"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + const initial = await reader.read(); + expect(initial.value).toBeTruthy(); + expect(decoder.decode(initial.value, { stream: true }).startsWith(": ping\n\n")).toBe(true); + + await vi.advanceTimersByTimeAsync(5000); + const second = await reader.read(); + expect(decoder.decode(second.value, { stream: true })).toContain(": ping\n\n"); + + await vi.advanceTimersByTimeAsync(5000); + const third = await reader.read(); + expect(decoder.decode(third.value, { stream: true })).toContain(": ping\n\n"); + + if (releaseAttempt) releaseAttempt(); + await vi.runAllTimersAsync(); + + let finalBuffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + finalBuffer += decoder.decode(value, { stream: true }); + } + expect(finalBuffer).toContain("event: message_stop"); + }); + + test("client abort closes the response without emitting success terminator", async () => { + const abortController = new AbortController(); + + let abortFired = false; + const performAttempt = vi.fn(async (_index: number, signal: AbortSignal) => { + return new Promise((_, reject) => { + signal.addEventListener("abort", () => { + abortFired = true; + reject(Object.assign(new Error("aborted"), { name: "AbortError" })); + }); + }); + }); + + const response = buildFakeStreamingResponse({ + family: "anthropic", + isStream: true, + performAttempt, + abortSignal: abortController.signal, + maxAttempts: 5, + heartbeatIntervalMs: 5000, + }); + + const { reader, text } = await readUntilFirstChunk(response.body); + expect(text.startsWith(": ping\n\n")).toBe(true); + + abortController.abort(); + await vi.runAllTimersAsync(); + + let buffer = ""; + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + expect(abortFired).toBe(true); + expect(buffer).not.toContain("event: message_stop"); + }); +}); + +describe("buildFakeStreamingNonStreamResponse — non-stream path", () => { + test("returns final JSON body verbatim without heartbeat for non-stream client", async () => { + const performAttempt: AttemptPerformer = async () => ({ + status: 200, + body: validBody, + providerId: "p1", + }); + + const response = await buildFakeStreamingNonStreamResponse({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 5, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + const body = await response.text(); + expect(body).toBe(validBody); + }); + + test("returns 502 JSON error when all attempts fail", async () => { + const performAttempt: AttemptPerformer = async () => ({ + status: 200, + body: emptyBody, + providerId: "p1", + }); + + const response = await buildFakeStreamingNonStreamResponse({ + family: "anthropic", + performAttempt, + abortSignal: new AbortController().signal, + maxAttempts: 2, + }); + + expect(response.status).toBe(502); + expect(response.headers.get("content-type")).toContain("application/json"); + const body = await response.json(); + expect(body.error).toBeTruthy(); + expect(body.error.code).toBe("upstream_all_attempts_failed"); + }); +}); diff --git a/tests/unit/settings/system-settings-form-fake-streaming.test.tsx b/tests/unit/settings/system-settings-form-fake-streaming.test.tsx new file mode 100644 index 000000000..5d43a2cf0 --- /dev/null +++ b/tests/unit/settings/system-settings-form-fake-streaming.test.tsx @@ -0,0 +1,292 @@ +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 { beforeEach, describe, expect, test, vi } from "vitest"; +import { SystemSettingsForm } from "@/app/[locale]/settings/config/_components/system-settings-form"; +import type { SystemSettings } from "@/types/system-config"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +const systemConfigActionMocks = vi.hoisted(() => ({ + saveSystemSettings: vi.fn(async () => ({ ok: true })), +})); +vi.mock("@/actions/system-config", () => systemConfigActionMocks); + +const requestFiltersActionMocks = vi.hoisted(() => ({ + getDistinctProviderGroupsAction: vi.fn(async () => ({ ok: true, data: ["group-a", "group-b"] })), +})); +vi.mock("@/actions/request-filters", () => requestFiltersActionMocks); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +const baseSettings = { + siteTitle: "Claude Code Hub", + allowGlobalUsageView: true, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "requested", + timezone: "UTC", + verboseProviderError: false, + passThroughUpstreamErrorMessage: true, + enableHttp2: true, + enableHighConcurrencyMode: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, + enableCodexSessionIdCompletion: true, + enableClaudeMetadataUserIdInjection: true, + enableResponseFixer: true, + allowNonConversationEndpointProviderFallback: true, + fakeStreamingWhitelist: [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gemini-3.1-flash-image-preview", groupTags: [] }, + ], + responseFixerConfig: { + fixEncoding: true, + fixSseFormat: true, + fixTruncatedJson: true, + }, + quotaDbRefreshIntervalSeconds: 10, + quotaLeasePercent5h: 0.05, + quotaLeasePercentDaily: 0.05, + quotaLeasePercentWeekly: 0.05, + quotaLeasePercentMonthly: 0.05, + quotaLeaseCapUsd: null, + ipGeoLookupEnabled: true, + ipExtractionConfig: null, +} satisfies Pick< + SystemSettings, + | "siteTitle" + | "allowGlobalUsageView" + | "currencyDisplay" + | "billingModelSource" + | "codexPriorityBillingSource" + | "timezone" + | "verboseProviderError" + | "passThroughUpstreamErrorMessage" + | "enableHttp2" + | "enableHighConcurrencyMode" + | "interceptAnthropicWarmupRequests" + | "enableThinkingSignatureRectifier" + | "enableThinkingBudgetRectifier" + | "enableBillingHeaderRectifier" + | "enableResponseInputRectifier" + | "enableCodexSessionIdCompletion" + | "enableClaudeMetadataUserIdInjection" + | "enableResponseFixer" + | "allowNonConversationEndpointProviderFallback" + | "fakeStreamingWhitelist" + | "responseFixerConfig" + | "quotaDbRefreshIntervalSeconds" + | "quotaLeasePercent5h" + | "quotaLeasePercentDaily" + | "quotaLeasePercentWeekly" + | "quotaLeasePercentMonthly" + | "quotaLeaseCapUsd" + | "ipGeoLookupEnabled" + | "ipExtractionConfig" +>; + +function loadMessages(locale: string) { + const base = path.join(process.cwd(), `messages/${locale}/settings`); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + settings: { + common: read("common.json"), + config: read("config.json"), + requestFilters: read("requestFilters.json"), + }, + }; +} + +function render(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function submitForm() { + const form = document.body.querySelector("form"); + if (!form) throw new Error("未找到系统设置表单"); + + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function findRemoveButtons(): HTMLButtonElement[] { + return Array.from( + document.querySelectorAll('button[data-testid^="fake-streaming-remove-"]') + ); +} + +function findAddButton(): HTMLButtonElement | null { + return document.querySelector('button[data-testid="fake-streaming-add"]'); +} + +function findModelInputs(): HTMLInputElement[] { + return Array.from( + document.querySelectorAll('input[data-testid^="fake-streaming-model-"]') + ); +} + +describe("SystemSettingsForm fake streaming whitelist", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + test("submits initial whitelist on save", async () => { + const { unmount } = render(); + + await submitForm(); + + expect(systemConfigActionMocks.saveSystemSettings).toHaveBeenCalledWith( + expect.objectContaining({ + fakeStreamingWhitelist: [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gemini-3.1-flash-image-preview", groupTags: [] }, + ], + }) + ); + + unmount(); + }); + + test("user can add a new model entry and saves it for all groups", async () => { + const { unmount } = render(); + + const addBtn = findAddButton(); + if (!addBtn) throw new Error("未找到 fake-streaming 添加按钮"); + + act(() => { + addBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const inputs = findModelInputs(); + expect(inputs.length).toBe(3); + const newRow = inputs[2]; + + act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(newRow, "custom-model-x"); + newRow.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await submitForm(); + + expect(systemConfigActionMocks.saveSystemSettings).toHaveBeenCalledWith( + expect.objectContaining({ + fakeStreamingWhitelist: [ + { model: "gpt-image-2", groupTags: [] }, + { model: "gemini-3.1-flash-image-preview", groupTags: [] }, + { model: "custom-model-x", groupTags: [] }, + ], + }) + ); + + unmount(); + }); + + test("user can remove a model entry and the empty whitelist is preserved as opt-out", async () => { + const singleEntry = { + ...baseSettings, + fakeStreamingWhitelist: [{ model: "gpt-image-2", groupTags: [] }], + } satisfies typeof baseSettings; + + const { unmount } = render(); + + const removeBtns = findRemoveButtons(); + expect(removeBtns.length).toBe(1); + + act(() => { + removeBtns[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await submitForm(); + + expect(systemConfigActionMocks.saveSystemSettings).toHaveBeenCalledWith( + expect.objectContaining({ + fakeStreamingWhitelist: [], + }) + ); + + unmount(); + }); + + test("trims whitespace and drops empty model entries before submitting", async () => { + const { unmount } = render(); + + const inputs = findModelInputs(); + expect(inputs.length).toBe(2); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + + act(() => { + setter?.call(inputs[0], " custom-image-model "); + inputs[0].dispatchEvent(new Event("input", { bubbles: true })); + setter?.call(inputs[1], " "); + inputs[1].dispatchEvent(new Event("input", { bubbles: true })); + }); + + await submitForm(); + + expect(systemConfigActionMocks.saveSystemSettings).toHaveBeenCalledWith( + expect.objectContaining({ + fakeStreamingWhitelist: [{ model: "custom-image-model", groupTags: [] }], + }) + ); + + unmount(); + }); + + test("all locales define fake streaming labels", () => { + const locales = ["zh-CN", "zh-TW", "en", "ja", "ru"] as const; + + for (const locale of locales) { + const config = loadMessages(locale).settings.config; + const section = config.form.fakeStreaming; + expect(section, `missing fakeStreaming section in ${locale}`).toBeTruthy(); + expect(section.title).toBeTruthy(); + expect(section.description).toBeTruthy(); + expect(section.modelLabel).toBeTruthy(); + expect(section.groupsLabel).toBeTruthy(); + expect(section.allGroupsHint).toBeTruthy(); + expect(section.addModel).toBeTruthy(); + expect(section.remove).toBeTruthy(); + expect(section.modelPlaceholder).toBeTruthy(); + expect(section.emptyState).toBeTruthy(); + } + }); +}); diff --git a/tests/unit/settings/system-settings-form-ip-extraction.test.tsx b/tests/unit/settings/system-settings-form-ip-extraction.test.tsx index 770897485..b11abfe0c 100644 --- a/tests/unit/settings/system-settings-form-ip-extraction.test.tsx +++ b/tests/unit/settings/system-settings-form-ip-extraction.test.tsx @@ -57,6 +57,7 @@ const baseSettings = { enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, + fakeStreamingWhitelist: [], responseFixerConfig: { fixEncoding: true, fixSseFormat: true, diff --git a/tests/unit/settings/system-settings-form-non-chat-fallback.test.tsx b/tests/unit/settings/system-settings-form-non-chat-fallback.test.tsx index 19fa3fbdd..fd0d92a0c 100644 --- a/tests/unit/settings/system-settings-form-non-chat-fallback.test.tsx +++ b/tests/unit/settings/system-settings-form-non-chat-fallback.test.tsx @@ -45,6 +45,7 @@ const baseSettings = { enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, allowNonConversationEndpointProviderFallback: true, + fakeStreamingWhitelist: [], responseFixerConfig: { fixEncoding: true, fixSseFormat: true, @@ -78,6 +79,7 @@ const baseSettings = { | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" | "allowNonConversationEndpointProviderFallback" + | "fakeStreamingWhitelist" | "responseFixerConfig" | "quotaDbRefreshIntervalSeconds" | "quotaLeasePercent5h" From 08ccfd679179b6b09ad7d81ec6f96f725f59dfda Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 28 Apr 2026 16:27:35 +0800 Subject: [PATCH 05/47] feat(providers): add provider-level custom headers (#943, #944) Persist providers.custom_headers (jsonb) and merge it into outbound proxy requests with a strict precedence: default outbound overrides -> provider customHeaders -> auth headers -> final request filter. Both the provider edit form (Options section) and the API test button render a JSON textarea wired to a shared parser/validator that returns stable error codes for localized messages. Validation rejects protected auth names (authorization, x-api-key, x-goog-api-key), CRLF, non-string values, malformed JSON, duplicate names (case-insensitive), and invalid HTTP token names. Empty input and {} normalize to null. The forwarder defensively re-strips protected names so a stale DB row cannot bypass auth. Includes 81 new tests across the shared parser, validation schemas, proxy forwarder, provider test action, and the two textareas. Audit emit redacts custom_headers/customHeaders to keep secrets out of audit trails. Five-locale i18n added under apiTest.customHeaders and sections.routing.customHeaders. Closes #943, closes #944. Co-Authored-By: Claude Opus 4.7 (1M context) --- drizzle/0099_nervous_squadron_sinister.sql | 1 + drizzle/meta/0099_snapshot.json | 4477 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- .../en/settings/providers/form/apiTest.json | 15 + .../en/settings/providers/form/sections.json | 14 + .../ja/settings/providers/form/apiTest.json | 15 + .../ja/settings/providers/form/sections.json | 14 + .../ru/settings/providers/form/apiTest.json | 15 + .../ru/settings/providers/form/sections.json | 14 + .../settings/providers/form/apiTest.json | 15 + .../settings/providers/form/sections.json | 14 + .../settings/providers/form/apiTest.json | 15 + .../settings/providers/form/sections.json | 14 + src/actions/providers.ts | 22 +- .../_components/forms/api-test-button.tsx | 71 + .../_components/forms/provider-form/index.tsx | 39 +- .../provider-form/provider-form-context.tsx | 11 + .../provider-form/provider-form-types.ts | 3 + .../sections/options-section.tsx | 22 + .../sections/testing-section.tsx | 11 + src/app/v1/_lib/proxy/forwarder.ts | 22 + src/drizzle/schema.ts | 4 + src/lib/custom-headers.test.ts | 274 + src/lib/custom-headers.ts | 94 + src/lib/validation/schemas.test.ts | 138 + src/lib/validation/schemas.ts | 29 + src/repository/_shared/transformers.ts | 1 + src/repository/provider.ts | 8 + src/types/provider.ts | 14 + tests/unit/actions/providers-api-test.test.ts | 91 + tests/unit/proxy/proxy-forwarder.test.ts | 178 + .../providers/api-test-button.test.tsx | 246 + .../providers/options-section.test.tsx | 72 + 33 files changed, 5978 insertions(+), 4 deletions(-) create mode 100644 drizzle/0099_nervous_squadron_sinister.sql create mode 100644 drizzle/meta/0099_snapshot.json create mode 100644 src/lib/custom-headers.test.ts create mode 100644 src/lib/custom-headers.ts diff --git a/drizzle/0099_nervous_squadron_sinister.sql b/drizzle/0099_nervous_squadron_sinister.sql new file mode 100644 index 000000000..cc18ecf2d --- /dev/null +++ b/drizzle/0099_nervous_squadron_sinister.sql @@ -0,0 +1 @@ +ALTER TABLE "providers" ADD COLUMN "custom_headers" jsonb; \ No newline at end of file diff --git a/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json new file mode 100644 index 000000000..1a312fc42 --- /dev/null +++ b/drizzle/meta/0099_snapshot.json @@ -0,0 +1,4477 @@ +{ + "id": "4c0c61bd-9bd1-4468-8211-311faa7379fa", + "prevId": "6014bb32-638d-4ca1-bb4b-16d9f3fe0e01", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "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 + }, + "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 + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "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\".\"status_code\" IS NOT NULL", + "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": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "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\".\"client_ip\" IS NOT 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_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "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(255)", + "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 + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": 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 + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "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": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "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'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "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 + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "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_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "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 + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "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 + }, + "actual_response_model": { + "name": "actual_response_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 + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": 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 + }, + "group_cost_multiplier": { + "name": "group_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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "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": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "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_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_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 + }, + "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 579a42619..f266938d4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1776965161943, "tag": "0098_equal_selene", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1777361216354, + "tag": "0099_nervous_squadron_sinister", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/settings/providers/form/apiTest.json b/messages/en/settings/providers/form/apiTest.json index fbd10d75c..802aa75be 100644 --- a/messages/en/settings/providers/form/apiTest.json +++ b/messages/en/settings/providers/form/apiTest.json @@ -13,6 +13,21 @@ "copyResult": "Copy Result", "copySuccess": "Copied to clipboard", "customConfig": "Custom", + "customHeaders": { + "label": "Custom request headers (JSON)", + "desc": "Static headers merged into the outbound test request. Will not override authentication.", + "geminiNotSupported": "Custom headers are not supported for the Gemini test path", + "errors": { + "invalidJson": "Custom headers must be valid JSON", + "notObject": "Custom headers must be a JSON object", + "invalidName": "Header name contains characters that are not allowed", + "duplicateName": "Header name appears more than once (case-insensitive)", + "protectedName": "Authentication headers cannot be set via custom headers", + "invalidValue": "Header values must be strings", + "emptyName": "Header name cannot be empty", + "crlf": "Header name and value cannot contain line breaks" + } + }, "customPayloadDesc": "Enter custom JSON payload to override default request body", "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}", "disclaimer": { diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index e6fa34f68..af9272aa2 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -190,6 +190,20 @@ "label": "Swap Cache TTL Billing", "desc": "Invert cache TTL for incoming data: 1h tokens treated as 5min and vice versa. Affects badge, cost, and all stored metrics." }, + "customHeaders": { + "label": "Custom request headers (JSON)", + "desc": "Static headers merged into outbound requests. Cannot override authentication or final request filters.", + "errors": { + "invalidJson": "Custom headers must be valid JSON", + "notObject": "Custom headers must be a JSON object", + "invalidName": "Header name contains characters that are not allowed", + "duplicateName": "Header name appears more than once (case-insensitive)", + "protectedName": "Authentication headers cannot be set via custom headers", + "invalidValue": "Header values must be strings", + "emptyName": "Header name cannot be empty", + "crlf": "Header name and value cannot contain line breaks" + } + }, "codexOverrides": { "title": "Codex Parameter Overrides", "desc": "Override Codex (Responses API) request parameters at the provider level", diff --git a/messages/ja/settings/providers/form/apiTest.json b/messages/ja/settings/providers/form/apiTest.json index 271173685..c9b6ae7fc 100644 --- a/messages/ja/settings/providers/form/apiTest.json +++ b/messages/ja/settings/providers/form/apiTest.json @@ -13,6 +13,21 @@ "copyResult": "結果をコピー", "copySuccess": "クリップボードにコピーしました", "customConfig": "カスタム", + "customHeaders": { + "label": "カスタムリクエストヘッダー(JSON)", + "desc": "静的なヘッダーがテストリクエストにマージされます。認証ヘッダーは上書きされません。", + "geminiNotSupported": "Gemini テスト経路ではカスタムヘッダーをサポートしていません", + "errors": { + "invalidJson": "カスタムヘッダーは有効な JSON である必要があります", + "notObject": "カスタムヘッダーは JSON オブジェクトである必要があります", + "invalidName": "ヘッダー名に許可されていない文字が含まれています", + "duplicateName": "ヘッダー名が重複しています(大文字小文字を区別しません)", + "protectedName": "認証ヘッダーはカスタムヘッダーで設定できません", + "invalidValue": "ヘッダー値は文字列である必要があります", + "emptyName": "ヘッダー名は空にできません", + "crlf": "ヘッダー名と値に改行を含めることはできません" + } + }, "customPayloadDesc": "カスタムJSONペイロードを入力してデフォルトのリクエストボディを上書き", "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}", "disclaimer": { diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index f9e2ee2ac..f6417e415 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -190,6 +190,20 @@ "label": "キャッシュTTL課金スワップ", "desc": "受信データのキャッシュTTLを反転:1hトークンを5分として扱い、その逆も同様。バッジ、コスト、保存メトリクスすべてに影響します。" }, + "customHeaders": { + "label": "カスタムリクエストヘッダー(JSON)", + "desc": "静的なヘッダーが送信リクエストにマージされます。認証ヘッダーや最終リクエストフィルターを上書きしません。", + "errors": { + "invalidJson": "カスタムヘッダーは有効な JSON である必要があります", + "notObject": "カスタムヘッダーは JSON オブジェクトである必要があります", + "invalidName": "ヘッダー名に許可されていない文字が含まれています", + "duplicateName": "ヘッダー名が重複しています(大文字小文字を区別しません)", + "protectedName": "認証ヘッダーはカスタムヘッダーで設定できません", + "invalidValue": "ヘッダー値は文字列である必要があります", + "emptyName": "ヘッダー名は空にできません", + "crlf": "ヘッダー名と値に改行を含めることはできません" + } + }, "codexOverrides": { "title": "Codex パラメータオーバーライド", "desc": "プロバイダーレベルで Codex (Responses API) リクエストパラメータをオーバーライド", diff --git a/messages/ru/settings/providers/form/apiTest.json b/messages/ru/settings/providers/form/apiTest.json index f983a7802..7119064df 100644 --- a/messages/ru/settings/providers/form/apiTest.json +++ b/messages/ru/settings/providers/form/apiTest.json @@ -13,6 +13,21 @@ "copyResult": "Копировать результат", "copySuccess": "Скопировано в буфер обмена", "customConfig": "Пользовательский", + "customHeaders": { + "label": "Пользовательские заголовки запроса (JSON)", + "desc": "Статические заголовки добавляются к тестовому запросу. Не перекрывают заголовки аутентификации.", + "geminiNotSupported": "Тестовый канал Gemini не поддерживает пользовательские заголовки", + "errors": { + "invalidJson": "Пользовательские заголовки должны быть корректным JSON", + "notObject": "Пользовательские заголовки должны быть JSON-объектом", + "invalidName": "Имя заголовка содержит недопустимые символы", + "duplicateName": "Имя заголовка повторяется (без учёта регистра)", + "protectedName": "Заголовки аутентификации нельзя задать через пользовательские заголовки", + "invalidValue": "Значения заголовков должны быть строками", + "emptyName": "Имя заголовка не может быть пустым", + "crlf": "Имя и значение заголовка не могут содержать переводы строки" + } + }, "customPayloadDesc": "Введите пользовательский JSON payload для замены тела запроса по умолчанию", "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}", "disclaimer": { diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 3541dd0ce..69ed6f9cf 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -190,6 +190,20 @@ "label": "Инверсия тарификации Cache TTL", "desc": "Инвертировать TTL кэша на входе: токены 1h обрабатываются как 5 мин и наоборот. Влияет на бейдж, стоимость и все сохраняемые метрики." }, + "customHeaders": { + "label": "Пользовательские заголовки запроса (JSON)", + "desc": "Статические заголовки добавляются к исходящим запросам. Не перекрывают заголовки аутентификации и финальные request filter.", + "errors": { + "invalidJson": "Пользовательские заголовки должны быть корректным JSON", + "notObject": "Пользовательские заголовки должны быть JSON-объектом", + "invalidName": "Имя заголовка содержит недопустимые символы", + "duplicateName": "Имя заголовка повторяется (без учёта регистра)", + "protectedName": "Заголовки аутентификации нельзя задать через пользовательские заголовки", + "invalidValue": "Значения заголовков должны быть строками", + "emptyName": "Имя заголовка не может быть пустым", + "crlf": "Имя и значение заголовка не могут содержать переводы строки" + } + }, "codexOverrides": { "title": "Переопределение параметров Codex", "desc": "Переопределение параметров запросов Codex (Responses API) на уровне провайдера", diff --git a/messages/zh-CN/settings/providers/form/apiTest.json b/messages/zh-CN/settings/providers/form/apiTest.json index 03aec7df7..f9a90e48b 100644 --- a/messages/zh-CN/settings/providers/form/apiTest.json +++ b/messages/zh-CN/settings/providers/form/apiTest.json @@ -19,6 +19,21 @@ "requestConfig": "请求配置", "presetConfig": "预置配置", "customConfig": "自定义", + "customHeaders": { + "label": "自定义请求头(JSON)", + "desc": "静态请求头会合并到本次测试请求;不会覆盖鉴权头。", + "geminiNotSupported": "Gemini 测试通道暂不支持自定义请求头", + "errors": { + "invalidJson": "自定义请求头必须是合法 JSON", + "notObject": "自定义请求头必须是 JSON 对象", + "invalidName": "请求头名称包含不允许的字符", + "duplicateName": "请求头名称重复(不区分大小写)", + "protectedName": "鉴权请求头不能通过自定义请求头设置", + "invalidValue": "请求头值必须为字符串", + "emptyName": "请求头名称不能为空", + "crlf": "请求头名称和值不能包含换行符" + } + }, "selectPreset": "选择预置模板", "presetDesc": "预置模板包含真实 CLI 请求特征,用于通过中转服务验证", "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}", diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index 9db688457..83bd723b1 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -146,6 +146,20 @@ "label": "Cache TTL 计费互换", "desc": "反转传入数据的缓存 TTL:1h 令牌视为 5 分钟,反之亦然。影响标记、成本及所有存储指标。" }, + "customHeaders": { + "label": "自定义请求头(JSON)", + "desc": "静态请求头会合并到出站请求;不会覆盖鉴权头或最终 request filter。", + "errors": { + "invalidJson": "自定义请求头必须是合法 JSON", + "notObject": "自定义请求头必须是 JSON 对象", + "invalidName": "请求头名称包含不允许的字符", + "duplicateName": "请求头名称重复(不区分大小写)", + "protectedName": "鉴权请求头不能通过自定义请求头设置", + "invalidValue": "请求头值必须为字符串", + "emptyName": "请求头名称不能为空", + "crlf": "请求头名称和值不能包含换行符" + } + }, "codexOverrides": { "title": "Codex 参数覆写", "desc": "在供应商级别覆写 Codex (Responses API) 请求参数", diff --git a/messages/zh-TW/settings/providers/form/apiTest.json b/messages/zh-TW/settings/providers/form/apiTest.json index 191a03051..5cac1e496 100644 --- a/messages/zh-TW/settings/providers/form/apiTest.json +++ b/messages/zh-TW/settings/providers/form/apiTest.json @@ -13,6 +13,21 @@ "copyResult": "複製結果", "copySuccess": "已複製到剪貼簿", "customConfig": "自訂", + "customHeaders": { + "label": "自訂請求標頭(JSON)", + "desc": "靜態請求標頭會合併到本次測試請求;不會覆蓋鑑權標頭。", + "geminiNotSupported": "Gemini 測試通道暫不支援自訂請求標頭", + "errors": { + "invalidJson": "自訂請求標頭必須為合法 JSON", + "notObject": "自訂請求標頭必須為 JSON 物件", + "invalidName": "請求標頭名稱包含不允許的字元", + "duplicateName": "請求標頭名稱重複(不區分大小寫)", + "protectedName": "鑑權請求標頭不能透過自訂請求標頭設定", + "invalidValue": "請求標頭值必須為字串", + "emptyName": "請求標頭名稱不能為空", + "crlf": "請求標頭名稱和值不能包含換行符" + } + }, "customPayloadDesc": "輸入自訂 JSON payload,將覆蓋預設請求主體", "customPayloadPlaceholder": "{\"model\": \"...\", \"messages\": [...]}", "disclaimer": { diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 7974b7c0c..892078e2e 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -190,6 +190,20 @@ "label": "Cache TTL 計費互換", "desc": "反轉傳入資料的快取 TTL:1h 令牌視為 5 分鐘,反之亦然。影響標記、成本及所有儲存指標。" }, + "customHeaders": { + "label": "自訂請求標頭(JSON)", + "desc": "靜態請求標頭會合併到出站請求;不會覆蓋鑑權標頭或最終 request filter。", + "errors": { + "invalidJson": "自訂請求標頭必須為合法 JSON", + "notObject": "自訂請求標頭必須為 JSON 物件", + "invalidName": "請求標頭名稱包含不允許的字元", + "duplicateName": "請求標頭名稱重複(不區分大小寫)", + "protectedName": "鑑權請求標頭不能透過自訂請求標頭設定", + "invalidValue": "請求標頭值必須為字串", + "emptyName": "請求標頭名稱不能為空", + "crlf": "請求標頭名稱和值不能包含換行符" + } + }, "codexOverrides": { "title": "Codex 參數覆寫", "desc": "在供應商級別覆寫 Codex (Responses API) 請求參數", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 8ff7c5aba..df032a058 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -21,6 +21,7 @@ import { resetCircuit, } from "@/lib/circuit-breaker"; import { PROVIDER_GROUP, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; +import { normalizeCustomHeadersRecord } from "@/lib/custom-headers"; import { logger } from "@/lib/logger"; import { PROVIDER_ALLOWED_MODEL_RULE_INPUT_LIST_SCHEMA } from "@/lib/provider-allowed-model-schema"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; @@ -335,6 +336,7 @@ export async function getProviders(): Promise { circuitBreakerHalfOpenSuccessThreshold: provider.circuitBreakerHalfOpenSuccessThreshold, proxyUrl: provider.proxyUrl, proxyFallbackToDirect: provider.proxyFallbackToDirect, + customHeaders: provider.customHeaders, firstByteTimeoutStreamingMs: provider.firstByteTimeoutStreamingMs, streamingIdleTimeoutMs: provider.streamingIdleTimeoutMs, requestTimeoutNonStreamingMs: provider.requestTimeoutNonStreamingMs, @@ -541,6 +543,7 @@ export async function addProvider(data: { circuit_breaker_half_open_success_threshold?: number; proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + custom_headers?: Record | null; first_byte_timeout_streaming_ms?: number; streaming_idle_timeout_ms?: number; request_timeout_non_streaming_ms?: number; @@ -755,6 +758,7 @@ export async function editProvider( circuit_breaker_half_open_success_threshold?: number; proxy_url?: string | null; proxy_fallback_to_direct?: boolean; + custom_headers?: Record | null; first_byte_timeout_streaming_ms?: number; streaming_idle_timeout_ms?: number; request_timeout_non_streaming_ms?: number; @@ -937,7 +941,7 @@ export async function editProvider( before: preimageFields, after: data, success: true, - redactExtraKeys: ["key"], + redactExtraKeys: ["key", "custom_headers", "customHeaders"], }); return { ok: true, @@ -1457,6 +1461,7 @@ const SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY: Record circuit_breaker_half_open_success_threshold: "circuitBreakerHalfOpenSuccessThreshold", proxy_url: "proxyUrl", proxy_fallback_to_direct: "proxyFallbackToDirect", + custom_headers: "customHeaders", first_byte_timeout_streaming_ms: "firstByteTimeoutStreamingMs", streaming_idle_timeout_ms: "streamingIdleTimeoutMs", request_timeout_non_streaming_ms: "requestTimeoutNonStreamingMs", @@ -4625,6 +4630,19 @@ export async function testProviderUnified(data: UnifiedTestArgs): Promise | undefined; + if (data.customHeaders !== undefined) { + const headerValidation = normalizeCustomHeadersRecord(data.customHeaders); + if (!headerValidation.ok) { + return { + ok: false, + error: `custom_headers_${headerValidation.code}`, + }; + } + normalizedCustomHeaders = headerValidation.value ?? undefined; + } + try { // Build test configuration const config: ProviderTestConfig = { @@ -4640,7 +4658,7 @@ export async function testProviderUnified(data: UnifiedTestArgs): Promise | null; enableMultiProviderTypes: boolean; } @@ -94,6 +102,7 @@ export function ApiTestButton({ providerId, providerType, allowedModels = [], + customHeaders, enableMultiProviderTypes: _enableMultiProviderTypes, }: ApiTestButtonProps) { const t = useTranslations("settings.providers.form.apiTest"); @@ -113,6 +122,14 @@ export function ApiTestButton({ const [testModel, setTestModel] = useState(() => getDefaultModelForProvider(providerType, normalizedAllowedModels[0]) ); + const initialCustomHeadersText = useMemo( + () => stringifyCustomHeadersForTextarea(customHeaders ?? null), + // 仅根据 providerId + 序列化值计算初始值,避免引用相等触发不必要的更新 + // eslint-disable-next-line react-hooks/exhaustive-deps + [providerId, JSON.stringify(customHeaders ?? null)] + ); + const [customHeadersText, setCustomHeadersText] = useState(initialCustomHeadersText); + const [isCustomHeadersManuallyEdited, setIsCustomHeadersManuallyEdited] = useState(false); const [testResult, setTestResult] = useState(null); useEffect(() => { @@ -123,6 +140,27 @@ export function ApiTestButton({ setTestModel(getDefaultModelForProvider(providerType, normalizedAllowedModels[0])); }, [isModelManuallyEdited, normalizedAllowedModels, providerType]); + // 仅在用户未手动编辑时随 prop 变更同步;切换 provider 身份时重置编辑标志 + useEffect(() => { + setIsCustomHeadersManuallyEdited(false); + }, [providerId]); + + useEffect(() => { + if (isCustomHeadersManuallyEdited) return; + setCustomHeadersText(initialCustomHeadersText); + }, [initialCustomHeadersText, isCustomHeadersManuallyEdited]); + + const CUSTOM_HEADER_ERROR_KEYS: Record = { + invalid_json: "customHeaders.errors.invalidJson", + not_object: "customHeaders.errors.notObject", + invalid_name: "customHeaders.errors.invalidName", + duplicate_name: "customHeaders.errors.duplicateName", + protected_name: "customHeaders.errors.protectedName", + invalid_value: "customHeaders.errors.invalidValue", + empty_name: "customHeaders.errors.emptyName", + crlf: "customHeaders.errors.crlf", + }; + const handleTest = async () => { if (!providerUrl.trim()) { toast.error(t("fillUrlFirst")); @@ -134,6 +172,21 @@ export function ApiTestButton({ return; } + const parsedCustomHeaders = parseCustomHeadersJsonText(customHeadersText); + if (!parsedCustomHeaders.ok) { + toast.error(t(CUSTOM_HEADER_ERROR_KEYS[parsedCustomHeaders.code])); + return; + } + const customHeadersValue = parsedCustomHeaders.value; + + if ( + customHeadersValue && + (resolvedProviderType === "gemini" || resolvedProviderType === "gemini-cli") + ) { + toast.warning(t("customHeaders.geminiNotSupported")); + return; + } + setIsTesting(true); setTestResult(null); @@ -276,6 +329,7 @@ export function ApiTestButton({ proxyUrl: proxyUrl?.trim() || null, proxyFallbackToDirect, timeoutMs: getTimeoutMsForProvider(resolvedProviderType), + customHeaders: customHeadersValue ?? undefined, }); if (!response.ok) { @@ -388,6 +442,23 @@ export function ApiTestButton({
{t("testModelDesc")}
+
+ +