diff --git a/drizzle/0019_proxy_logs_stream_timing.sql b/drizzle/0019_proxy_logs_stream_timing.sql new file mode 100644 index 0000000..77e1861 --- /dev/null +++ b/drizzle/0019_proxy_logs_stream_timing.sql @@ -0,0 +1,2 @@ +ALTER TABLE `proxy_logs` ADD `is_stream` integer;--> statement-breakpoint +ALTER TABLE `proxy_logs` ADD `first_byte_latency_ms` integer; \ No newline at end of file diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..5200d3a --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,2774 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "acb4042f-d8bc-4081-8866-4701e9e16fa4", + "prevId": "04823136-cf24-48e5-a75f-bc269de435c6", + "tables": { + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_group": { + "name": "token_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_status": { + "name": "value_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "account_tokens_account_id_idx": { + "name": "account_tokens_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_tokens_account_enabled_idx": { + "name": "account_tokens_account_enabled_idx", + "columns": [ + "account_id", + "enabled" + ], + "isUnique": false + }, + "account_tokens_enabled_idx": { + "name": "account_tokens_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_account_id_accounts_id_fk": { + "name": "account_tokens_account_id_accounts_id_fk", + "tableFrom": "account_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "balance_used": { + "name": "balance_used", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "quota": { + "name": "quota", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unit_cost": { + "name": "unit_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_score": { + "name": "value_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "checkin_enabled": { + "name": "checkin_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_checkin_at": { + "name": "last_checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_balance_refresh": { + "name": "last_balance_refresh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_account_key": { + "name": "oauth_account_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_project_id": { + "name": "oauth_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_config": { + "name": "extra_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "accounts_site_id_idx": { + "name": "accounts_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "accounts_site_status_idx": { + "name": "accounts_site_status_idx", + "columns": [ + "site_id", + "status" + ], + "isUnique": false + }, + "accounts_oauth_provider_idx": { + "name": "accounts_oauth_provider_idx", + "columns": [ + "oauth_provider" + ], + "isUnique": false + }, + "accounts_oauth_identity_idx": { + "name": "accounts_oauth_identity_idx", + "columns": [ + "oauth_provider", + "oauth_account_key", + "oauth_project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_site_id_sites_id_fk": { + "name": "accounts_site_id_sites_id_fk", + "tableFrom": "accounts", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checkin_logs": { + "name": "checkin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward": { + "name": "reward", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "checkin_logs_account_created_at_idx": { + "name": "checkin_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "checkin_logs_created_at_idx": { + "name": "checkin_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "checkin_logs_status_idx": { + "name": "checkin_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checkin_logs_account_id_accounts_id_fk": { + "name": "checkin_logs_account_id_accounts_id_fk", + "tableFrom": "checkin_logs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downstream_api_keys": { + "name": "downstream_api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_cost": { + "name": "max_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_cost": { + "name": "used_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_requests": { + "name": "max_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_requests": { + "name": "used_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "supported_models": { + "name": "supported_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_route_ids": { + "name": "allowed_route_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_weight_multipliers": { + "name": "site_weight_multipliers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "downstream_api_keys_key_unique": { + "name": "downstream_api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "downstream_api_keys_name_idx": { + "name": "downstream_api_keys_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "downstream_api_keys_enabled_idx": { + "name": "downstream_api_keys_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downstream_api_keys_expires_at_idx": { + "name": "downstream_api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "related_id": { + "name": "related_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "events_read_created_at_idx": { + "name": "events_read_created_at_idx", + "columns": [ + "read", + "created_at" + ], + "isUnique": false + }, + "events_type_created_at_idx": { + "name": "events_type_created_at_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_availability": { + "name": "model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "model_availability_account_model_unique": { + "name": "model_availability_account_model_unique", + "columns": [ + "account_id", + "model_name" + ], + "isUnique": true + }, + "model_availability_account_available_idx": { + "name": "model_availability_account_available_idx", + "columns": [ + "account_id", + "available" + ], + "isUnique": false + }, + "model_availability_model_name_idx": { + "name": "model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_availability_account_id_accounts_id_fk": { + "name": "model_availability_account_id_accounts_id_fk", + "tableFrom": "model_availability", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_debug_attempts": { + "name": "proxy_debug_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "trace_id": { + "name": "trace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_index": { + "name": "attempt_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_path": { + "name": "request_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtime_executor": { + "name": "runtime_executor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_headers_json": { + "name": "request_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body_json": { + "name": "request_body_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers_json": { + "name": "response_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body_json": { + "name": "response_body_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_error_text": { + "name": "raw_error_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recover_applied": { + "name": "recover_applied", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "downgrade_decision": { + "name": "downgrade_decision", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "downgrade_reason": { + "name": "downgrade_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_write_json": { + "name": "memory_write_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_debug_attempts_trace_attempt_unique": { + "name": "proxy_debug_attempts_trace_attempt_unique", + "columns": [ + "trace_id", + "attempt_index" + ], + "isUnique": true + }, + "proxy_debug_attempts_trace_created_at_idx": { + "name": "proxy_debug_attempts_trace_created_at_idx", + "columns": [ + "trace_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "proxy_debug_attempts_trace_id_proxy_debug_traces_id_fk": { + "name": "proxy_debug_attempts_trace_id_proxy_debug_traces_id_fk", + "tableFrom": "proxy_debug_attempts", + "tableTo": "proxy_debug_traces", + "columnsFrom": [ + "trace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_debug_traces": { + "name": "proxy_debug_traces", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "downstream_path": { + "name": "downstream_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_kind": { + "name": "client_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_hint": { + "name": "trace_hint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downstream_api_key_id": { + "name": "downstream_api_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_headers_json": { + "name": "request_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body_json": { + "name": "request_body_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sticky_session_key": { + "name": "sticky_session_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sticky_hit_channel_id": { + "name": "sticky_hit_channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_channel_id": { + "name": "selected_channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_route_id": { + "name": "selected_route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_account_id": { + "name": "selected_account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_site_id": { + "name": "selected_site_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_site_platform": { + "name": "selected_site_platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_candidates_json": { + "name": "endpoint_candidates_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "endpoint_runtime_state_json": { + "name": "endpoint_runtime_state_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_summary_json": { + "name": "decision_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_status": { + "name": "final_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_http_status": { + "name": "final_http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_upstream_path": { + "name": "final_upstream_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_response_headers_json": { + "name": "final_response_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_response_body_json": { + "name": "final_response_body_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_debug_traces_created_at_idx": { + "name": "proxy_debug_traces_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_debug_traces_session_created_at_idx": { + "name": "proxy_debug_traces_session_created_at_idx", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + }, + "proxy_debug_traces_model_created_at_idx": { + "name": "proxy_debug_traces_model_created_at_idx", + "columns": [ + "requested_model", + "created_at" + ], + "isUnique": false + }, + "proxy_debug_traces_final_status_created_at_idx": { + "name": "proxy_debug_traces_final_status_created_at_idx", + "columns": [ + "final_status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_files": { + "name": "proxy_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "proxy_files_public_id_unique": { + "name": "proxy_files_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_files_owner_lookup_idx": { + "name": "proxy_files_owner_lookup_idx", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_logs": { + "name": "proxy_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downstream_api_key_id": { + "name": "downstream_api_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_requested": { + "name": "model_requested", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_actual": { + "name": "model_actual", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stream": { + "name": "is_stream", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_byte_latency_ms": { + "name": "first_byte_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_details": { + "name": "billing_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_family": { + "name": "client_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_app_id": { + "name": "client_app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_app_name": { + "name": "client_app_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_confidence": { + "name": "client_confidence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_logs_created_at_idx": { + "name": "proxy_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_logs_account_created_at_idx": { + "name": "proxy_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_status_created_at_idx": { + "name": "proxy_logs_status_created_at_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_model_actual_created_at_idx": { + "name": "proxy_logs_model_actual_created_at_idx", + "columns": [ + "model_actual", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_downstream_api_key_created_at_idx": { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_client_app_id_created_at_idx": { + "name": "proxy_logs_client_app_id_created_at_idx", + "columns": [ + "client_app_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_client_family_created_at_idx": { + "name": "proxy_logs_client_family_created_at_idx", + "columns": [ + "client_family", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_video_tasks": { + "name": "proxy_video_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_video_id": { + "name": "upstream_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_value": { + "name": "token_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_model": { + "name": "actual_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_snapshot": { + "name": "status_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_response_meta": { + "name": "upstream_response_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_upstream_status": { + "name": "last_upstream_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_video_tasks_public_id_unique": { + "name": "proxy_video_tasks_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_video_tasks_upstream_video_id_idx": { + "name": "proxy_video_tasks_upstream_video_id_idx", + "columns": [ + "upstream_video_id" + ], + "isUnique": false + }, + "proxy_video_tasks_created_at_idx": { + "name": "proxy_video_tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_channels": { + "name": "route_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_model": { + "name": "source_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "fail_count": { + "name": "fail_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fail_at": { + "name": "last_fail_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_fail_count": { + "name": "consecutive_fail_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_level": { + "name": "cooldown_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_channels_route_id_idx": { + "name": "route_channels_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_channels_account_id_idx": { + "name": "route_channels_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "route_channels_token_id_idx": { + "name": "route_channels_token_id_idx", + "columns": [ + "token_id" + ], + "isUnique": false + }, + "route_channels_route_enabled_idx": { + "name": "route_channels_route_enabled_idx", + "columns": [ + "route_id", + "enabled" + ], + "isUnique": false + }, + "route_channels_route_token_idx": { + "name": "route_channels_route_token_idx", + "columns": [ + "route_id", + "token_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_channels_route_id_token_routes_id_fk": { + "name": "route_channels_route_id_token_routes_id_fk", + "tableFrom": "route_channels", + "tableTo": "token_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_account_id_accounts_id_fk": { + "name": "route_channels_account_id_accounts_id_fk", + "tableFrom": "route_channels", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_token_id_account_tokens_id_fk": { + "name": "route_channels_token_id_account_tokens_id_fk", + "tableFrom": "route_channels", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_group_sources": { + "name": "route_group_sources", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "group_route_id": { + "name": "group_route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_route_id": { + "name": "source_route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "route_group_sources_group_source_unique": { + "name": "route_group_sources_group_source_unique", + "columns": [ + "group_route_id", + "source_route_id" + ], + "isUnique": true + }, + "route_group_sources_source_route_id_idx": { + "name": "route_group_sources_source_route_id_idx", + "columns": [ + "source_route_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_group_sources_group_route_id_token_routes_id_fk": { + "name": "route_group_sources_group_route_id_token_routes_id_fk", + "tableFrom": "route_group_sources", + "tableTo": "token_routes", + "columnsFrom": [ + "group_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_group_sources_source_route_id_token_routes_id_fk": { + "name": "route_group_sources_source_route_id_token_routes_id_fk", + "tableFrom": "route_group_sources", + "tableTo": "token_routes", + "columnsFrom": [ + "source_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_announcements": { + "name": "site_announcements", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_created_at": { + "name": "upstream_created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_updated_at": { + "name": "upstream_updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "read_at": { + "name": "read_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "site_announcements_site_source_key_unique": { + "name": "site_announcements_site_source_key_unique", + "columns": [ + "site_id", + "source_key" + ], + "isUnique": true + }, + "site_announcements_site_id_first_seen_at_idx": { + "name": "site_announcements_site_id_first_seen_at_idx", + "columns": [ + "site_id", + "first_seen_at" + ], + "isUnique": false + }, + "site_announcements_read_at_idx": { + "name": "site_announcements_read_at_idx", + "columns": [ + "read_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_announcements_site_id_sites_id_fk": { + "name": "site_announcements_site_id_sites_id_fk", + "tableFrom": "site_announcements", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_api_endpoints": { + "name": "site_api_endpoints", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_reason": { + "name": "last_failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_api_endpoints_site_url_unique": { + "name": "site_api_endpoints_site_url_unique", + "columns": [ + "site_id", + "url" + ], + "isUnique": true + }, + "site_api_endpoints_site_enabled_sort_idx": { + "name": "site_api_endpoints_site_enabled_sort_idx", + "columns": [ + "site_id", + "enabled", + "sort_order" + ], + "isUnique": false + }, + "site_api_endpoints_site_cooldown_idx": { + "name": "site_api_endpoints_site_cooldown_idx", + "columns": [ + "site_id", + "cooldown_until" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_api_endpoints_site_id_sites_id_fk": { + "name": "site_api_endpoints_site_id_sites_id_fk", + "tableFrom": "site_api_endpoints", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_disabled_models": { + "name": "site_disabled_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_disabled_models_site_model_unique": { + "name": "site_disabled_models_site_model_unique", + "columns": [ + "site_id", + "model_name" + ], + "isUnique": true + }, + "site_disabled_models_site_id_idx": { + "name": "site_disabled_models_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_disabled_models_site_id_sites_id_fk": { + "name": "site_disabled_models_site_id_sites_id_fk", + "tableFrom": "site_disabled_models", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_checkin_url": { + "name": "external_checkin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_system_proxy": { + "name": "use_system_proxy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "global_weight": { + "name": "global_weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "sites_status_idx": { + "name": "sites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "sites_platform_url_unique": { + "name": "sites_platform_url_unique", + "columns": [ + "platform", + "url" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_model_availability": { + "name": "token_model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_model_availability_token_model_unique": { + "name": "token_model_availability_token_model_unique", + "columns": [ + "token_id", + "model_name" + ], + "isUnique": true + }, + "token_model_availability_token_available_idx": { + "name": "token_model_availability_token_available_idx", + "columns": [ + "token_id", + "available" + ], + "isUnique": false + }, + "token_model_availability_model_name_idx": { + "name": "token_model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + }, + "token_model_availability_available_idx": { + "name": "token_model_availability_available_idx", + "columns": [ + "available" + ], + "isUnique": false + } + }, + "foreignKeys": { + "token_model_availability_token_id_account_tokens_id_fk": { + "name": "token_model_availability_token_id_account_tokens_id_fk", + "tableFrom": "token_model_availability", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_routes": { + "name": "token_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "model_pattern": { + "name": "model_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_icon": { + "name": "display_icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route_mode": { + "name": "route_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pattern'" + }, + "model_mapping": { + "name": "model_mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_snapshot": { + "name": "decision_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_refreshed_at": { + "name": "decision_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_strategy": { + "name": "routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'weighted'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_routes_model_pattern_idx": { + "name": "token_routes_model_pattern_idx", + "columns": [ + "model_pattern" + ], + "isUnique": false + }, + "token_routes_enabled_idx": { + "name": "token_routes_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 608b977..9251ee6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1774959227309, "tag": "0018_site_api_endpoints", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1775058998448, + "tag": "0019_proxy_logs_stream_timing", + "breakpoints": true } ] } diff --git a/src/server/config.ts b/src/server/config.ts index 18fcbbc..b3f9373 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -118,6 +118,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) { dbSsl: parseBoolean(env.DB_SSL, false), requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT, routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)), + proxyFirstByteTimeoutSec: Math.max(0, Math.trunc(parseNumber(env.PROXY_FIRST_BYTE_TIMEOUT_SEC, 0))), tokenRouterFailureCooldownMaxSec: normalizeTokenRouterFailureCooldownMaxSec( parseNumber(env.TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC, TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING), ) ?? TOKEN_ROUTER_FAILURE_COOLDOWN_MAX_SEC_CEILING, diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index d07535b..713c150 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NUL CREATE TABLE IF NOT EXISTS `proxy_debug_traces` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `downstream_path` TEXT NOT NULL, `client_kind` TEXT, `session_id` TEXT, `trace_hint` TEXT, `requested_model` TEXT, `downstream_api_key_id` INT, `request_headers_json` JSON, `request_body_json` JSON, `sticky_session_key` TEXT, `sticky_hit_channel_id` INT, `selected_channel_id` INT, `selected_route_id` INT, `selected_account_id` INT, `selected_site_id` INT, `selected_site_platform` TEXT, `endpoint_candidates_json` JSON, `endpoint_runtime_state_json` JSON, `decision_summary_json` JSON, `final_status` TEXT, `final_http_status` INT, `final_upstream_path` TEXT, `final_response_headers_json` JSON, `final_response_body_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); CREATE TABLE IF NOT EXISTS `proxy_debug_attempts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `trace_id` INT NOT NULL, `attempt_index` INT NOT NULL, `endpoint` TEXT NOT NULL, `request_path` TEXT NOT NULL, `target_url` TEXT NOT NULL, `runtime_executor` TEXT, `request_headers_json` JSON, `request_body_json` JSON, `response_status` INT, `response_headers_json` JSON, `response_body_json` JSON, `raw_error_text` TEXT, `recover_applied` BOOLEAN DEFAULT false, `downgrade_decision` BOOLEAN DEFAULT false, `downgrade_reason` TEXT, `memory_write_json` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`trace_id`) REFERENCES `proxy_debug_traces`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `deleted_at` VARCHAR(191)); -CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT); +CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT, `is_stream` BOOLEAN, `first_byte_latency_ms` INT); CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` VARCHAR(191), `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern'); CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 0a3afd7..2565723 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1,4 +1,2 @@ -CREATE TABLE IF NOT EXISTS `site_api_endpoints` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `url` TEXT NOT NULL, `enabled` BOOLEAN DEFAULT true, `sort_order` INT DEFAULT 0, `cooldown_until` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_failed_at` VARCHAR(191), `last_failure_reason` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); -CREATE UNIQUE INDEX `site_api_endpoints_site_url_unique` ON `site_api_endpoints` (`site_id`, `url`(191)); -CREATE INDEX `site_api_endpoints_site_cooldown_idx` ON `site_api_endpoints` (`site_id`, `cooldown_until`); -CREATE INDEX `site_api_endpoints_site_enabled_sort_idx` ON `site_api_endpoints` (`site_id`, `enabled`, `sort_order`); +ALTER TABLE `proxy_logs` ADD COLUMN `is_stream` BOOLEAN; +ALTER TABLE `proxy_logs` ADD COLUMN `first_byte_latency_ms` INT; diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index 6c52e58..e558ca5 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAU CREATE TABLE IF NOT EXISTS "proxy_debug_traces" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "downstream_path" TEXT NOT NULL, "client_kind" TEXT, "session_id" TEXT, "trace_hint" TEXT, "requested_model" TEXT, "downstream_api_key_id" INTEGER, "request_headers_json" JSONB, "request_body_json" JSONB, "sticky_session_key" TEXT, "sticky_hit_channel_id" INTEGER, "selected_channel_id" INTEGER, "selected_route_id" INTEGER, "selected_account_id" INTEGER, "selected_site_id" INTEGER, "selected_site_platform" TEXT, "endpoint_candidates_json" JSONB, "endpoint_runtime_state_json" JSONB, "decision_summary_json" JSONB, "final_status" TEXT, "final_http_status" INTEGER, "final_upstream_path" TEXT, "final_response_headers_json" JSONB, "final_response_body_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); CREATE TABLE IF NOT EXISTS "proxy_debug_attempts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "trace_id" INTEGER NOT NULL, "attempt_index" INTEGER NOT NULL, "endpoint" TEXT NOT NULL, "request_path" TEXT NOT NULL, "target_url" TEXT NOT NULL, "runtime_executor" TEXT, "request_headers_json" JSONB, "request_body_json" JSONB, "response_status" INTEGER, "response_headers_json" JSONB, "response_body_json" JSONB, "raw_error_text" TEXT, "recover_applied" BOOLEAN DEFAULT false, "downgrade_decision" BOOLEAN DEFAULT false, "downgrade_reason" TEXT, "memory_write_json" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("trace_id") REFERENCES "proxy_debug_traces"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "deleted_at" TEXT); -CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT); +CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT, "is_stream" BOOLEAN, "first_byte_latency_ms" INTEGER); CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern'); CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index 4a17438..79e2716 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1,4 +1,2 @@ -CREATE TABLE IF NOT EXISTS "site_api_endpoints" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "url" TEXT NOT NULL, "enabled" BOOLEAN DEFAULT true, "sort_order" INTEGER DEFAULT 0, "cooldown_until" TEXT, "last_selected_at" TEXT, "last_failed_at" TEXT, "last_failure_reason" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); -CREATE UNIQUE INDEX "site_api_endpoints_site_url_unique" ON "site_api_endpoints" ("site_id", "url"); -CREATE INDEX "site_api_endpoints_site_cooldown_idx" ON "site_api_endpoints" ("site_id", "cooldown_until"); -CREATE INDEX "site_api_endpoints_site_enabled_sort_idx" ON "site_api_endpoints" ("site_id", "enabled", "sort_order"); +ALTER TABLE "proxy_logs" ADD COLUMN "is_stream" BOOLEAN; +ALTER TABLE "proxy_logs" ADD COLUMN "first_byte_latency_ms" INTEGER; diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index 9159156..782535a 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -949,6 +949,18 @@ "notNull": false, "defaultValue": null, "primaryKey": false + }, + "is_stream": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "first_byte_latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 13354b8..bebadb2 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -51,6 +51,7 @@ let pgPool: pg.Pool | null = null; let proxyLogBillingDetailsColumnAvailable: boolean | null = null; let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null; let proxyLogClientColumnsAvailable: boolean | null = null; +let proxyLogStreamTimingColumnsAvailable: boolean | null = null; function buildMysqlPoolOptions( connectionString = config.dbUrl, @@ -663,6 +664,21 @@ function ensureProxyLogClientSchema() { proxyLogClientColumnsAvailable = true; } +function ensureProxyLogStreamTimingSchema() { + if (!tableExists('proxy_logs')) { + return; + } + + if (!tableColumnExists('proxy_logs', 'is_stream')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;'); + } + if (!tableColumnExists('proxy_logs', 'first_byte_latency_ms')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN first_byte_latency_ms integer;'); + } + + proxyLogStreamTimingColumnsAvailable = true; +} + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -988,10 +1004,106 @@ export async function ensureProxyLogClientColumns(): Promise { } } +export async function hasProxyLogStreamTimingColumns(): Promise { + if (proxyLogStreamTimingColumnsAvailable !== null) { + return proxyLogStreamTimingColumnsAvailable; + } + + const requiredColumns = ['is_stream', 'first_byte_latency_ms']; + + if (runtimeDbDialect === 'sqlite') { + proxyLogStreamTimingColumnsAvailable = tableExists('proxy_logs') + && requiredColumns.every((columnName) => tableColumnExists('proxy_logs', columnName)); + return proxyLogStreamTimingColumnsAvailable; + } + + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + const [rows] = await mysqlPool.query( + 'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name IN (?, ?)', + ['proxy_logs', ...requiredColumns], + ) as [Array<{ column_name?: string }>, unknown]; + const available = new Set( + Array.isArray(rows) + ? rows.map((row) => String(row?.column_name || '').trim().toLowerCase()).filter(Boolean) + : [], + ); + proxyLogStreamTimingColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName)); + return proxyLogStreamTimingColumnsAvailable; + } + + if (!pgPool) return false; + const result = await pgPool.query( + 'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = ANY($2::text[])', + ['proxy_logs', requiredColumns], + ); + const available = new Set( + result.rows.map((row) => String((row as { column_name?: string }).column_name || '').trim().toLowerCase()).filter(Boolean), + ); + proxyLogStreamTimingColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName)); + return proxyLogStreamTimingColumnsAvailable; +} + +export async function ensureProxyLogStreamTimingColumns(): Promise { + const requiredColumns = [ + { name: 'is_stream', sqliteType: 'integer', mysqlType: 'BOOLEAN NULL', postgresType: 'BOOLEAN' }, + { name: 'first_byte_latency_ms', sqliteType: 'integer', mysqlType: 'INT NULL', postgresType: 'INTEGER' }, + ]; + + if (runtimeDbDialect === 'sqlite') { + ensureProxyLogStreamTimingSchema(); + proxyLogStreamTimingColumnsAvailable = tableExists('proxy_logs') + && requiredColumns.every((column) => tableColumnExists('proxy_logs', column.name)); + return proxyLogStreamTimingColumnsAvailable; + } + + if (await hasProxyLogStreamTimingColumns()) { + return true; + } + + try { + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + for (const column of requiredColumns) { + const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', [column.name]); + if (Array.isArray(rows) && rows.length > 0) continue; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + `ALTER TABLE \`proxy_logs\` ADD COLUMN \`${column.name}\` ${column.mysqlType}`, + ); + } + } else { + if (!pgPool) return false; + for (const column of requiredColumns) { + const result = await pgPool.query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1', + ['proxy_logs', column.name], + ); + if (Number(result.rowCount || 0) > 0) continue; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + `ALTER TABLE "proxy_logs" ADD COLUMN "${column.name}" ${column.postgresType}`, + ); + } + } + proxyLogStreamTimingColumnsAvailable = true; + return true; + } catch (error) { + if (isDuplicateColumnError(error)) { + proxyLogStreamTimingColumnsAvailable = await hasProxyLogStreamTimingColumns(); + return proxyLogStreamTimingColumnsAvailable; + } + proxyLogStreamTimingColumnsAvailable = false; + console.warn('[db] failed to ensure proxy_logs stream timing columns', error); + return false; + } +} + function resetSchemaCapabilityCache() { proxyLogBillingDetailsColumnAvailable = null; proxyLogDownstreamApiKeyIdColumnAvailable = null; proxyLogClientColumnsAvailable = null; + proxyLogStreamTimingColumnsAvailable = null; } async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) { diff --git a/src/server/db/legacySchemaCompat.test.ts b/src/server/db/legacySchemaCompat.test.ts index 6cd7ce5..dd723f2 100644 --- a/src/server/db/legacySchemaCompat.test.ts +++ b/src/server/db/legacySchemaCompat.test.ts @@ -4,6 +4,8 @@ import { classifyLegacyCompatMutation } from './legacySchemaCompat.js'; describe('legacy schema compat boundary', () => { it('allows only explicitly registered legacy upgrade shims', () => { expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy'); + expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN is_stream integer;')).toBe('legacy'); + expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN first_byte_latency_ms integer;')).toBe('legacy'); expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy'); expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).toBe('legacy'); expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy'); diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts index 57b6845..24aef59 100644 --- a/src/server/db/legacySchemaCompat.ts +++ b/src/server/db/legacySchemaCompat.ts @@ -54,6 +54,8 @@ const BOOTSTRAP_OWNED_LEGACY_COLUMNS = [ 'downstream_api_keys.group_name', 'downstream_api_keys.tags', 'proxy_logs.billing_details', + 'proxy_logs.is_stream', + 'proxy_logs.first_byte_latency_ms', 'proxy_logs.client_family', 'proxy_logs.client_app_id', 'proxy_logs.client_app_name', diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index a2758d4..384ceff 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -70,6 +70,9 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'downstream_api_keys', column: 'tags' }, // 0012: value_status column on account_tokens { table: 'account_tokens', column: 'value_status' }, + // 0019: proxy log stream/timing columns + { table: 'proxy_logs', column: 'is_stream' }, + { table: 'proxy_logs', column: 'first_byte_latency_ms' }, ]; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index ced7883..21a9f05 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -205,6 +205,8 @@ export const proxyLogs = sqliteTable('proxy_logs', { modelActual: text('model_actual'), status: text('status'), // 'success' | 'failed' | 'retried' httpStatus: integer('http_status'), + isStream: integer('is_stream', { mode: 'boolean' }), + firstByteLatencyMs: integer('first_byte_latency_ms'), latencyMs: integer('latency_ms'), promptTokens: integer('prompt_tokens'), completionTokens: integer('completion_tokens'), diff --git a/src/server/db/schemaParity.test.ts b/src/server/db/schemaParity.test.ts index 995eb49..76949e7 100644 --- a/src/server/db/schemaParity.test.ts +++ b/src/server/db/schemaParity.test.ts @@ -85,16 +85,22 @@ describe('database schema parity', () => { const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8'); expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer'); + expect(contract.tables.proxy_logs?.columns.is_stream?.logicalType).toBe('boolean'); + expect(contract.tables.proxy_logs?.columns.first_byte_latency_ms?.logicalType).toBe('integer'); expect(contract.tables.proxy_logs?.columns.client_app_id?.logicalType).toBe('text'); expect(contract.tables.proxy_logs?.columns.client_family?.logicalType).toBe('text'); expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true); expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_app_id_created_at_idx')).toBe(true); expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_family_created_at_idx')).toBe(true); expect(mysqlBootstrap).toContain('`downstream_api_key_id`'); + expect(mysqlBootstrap).toContain('`is_stream`'); + expect(mysqlBootstrap).toContain('`first_byte_latency_ms`'); expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`'); expect(mysqlBootstrap).toContain('`client_app_id`'); expect(mysqlBootstrap).toContain('`proxy_logs_client_app_id_created_at_idx`'); expect(postgresBootstrap).toContain('"downstream_api_key_id"'); + expect(postgresBootstrap).toContain('"is_stream"'); + expect(postgresBootstrap).toContain('"first_byte_latency_ms"'); expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"'); expect(postgresBootstrap).toContain('"client_app_id"'); expect(postgresBootstrap).toContain('"proxy_logs_client_app_id_created_at_idx"'); diff --git a/src/server/index.ts b/src/server/index.ts index 1c084b7..42918b1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -60,6 +60,7 @@ import { ensureProxyLogClientColumns, ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, + ensureProxyLogStreamTimingColumns, ensureRouteGroupingCompatibilityColumns, ensureSiteCompatibilityColumns, runtimeDbDialect, @@ -304,6 +305,11 @@ function applyRuntimeSettings(settingsMap: Map) { config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost); } + const proxyFirstByteTimeoutSec = parseSettingFromMap(settingsMap, 'proxy_first_byte_timeout_sec'); + if (typeof proxyFirstByteTimeoutSec === 'number' && Number.isFinite(proxyFirstByteTimeoutSec) && proxyFirstByteTimeoutSec >= 0) { + config.proxyFirstByteTimeoutSec = Math.max(0, Math.trunc(proxyFirstByteTimeoutSec)); + } + const tokenRouterFailureCooldownMaxSec = parseSettingFromMap(settingsMap, 'token_router_failure_cooldown_max_sec'); const normalizedFailureCooldownMaxSec = normalizeTokenRouterFailureCooldownMaxSec(tokenRouterFailureCooldownMaxSec); if (normalizedFailureCooldownMaxSec != null) { @@ -417,6 +423,7 @@ try { await ensureSiteCompatibilityColumns(); await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); + await ensureProxyLogStreamTimingColumns(); await ensureProxyLogClientColumns(); await ensureProxyLogDownstreamApiKeyIdColumn(); const finalRows = await db.select().from(schema.settings).all(); diff --git a/src/server/proxy-core/executors/types.ts b/src/server/proxy-core/executors/types.ts index 14008db..3bd2262 100644 --- a/src/server/proxy-core/executors/types.ts +++ b/src/server/proxy-core/executors/types.ts @@ -35,6 +35,7 @@ export type RuntimeDispatchInput = { siteUrl: string; request: ProxyRuntimeRequest; targetUrl?: string; + signal?: AbortSignal; buildInit: (requestUrl: string, request: ProxyRuntimeRequest) => Promise | UndiciRequestInit; }; @@ -72,7 +73,13 @@ export async function performFetch( requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path), ): Promise { const init = await input.buildInit(requestUrl, request); - return fetch(requestUrl, init); + const combinedSignal = input.signal && init.signal + ? AbortSignal.any([input.signal, init.signal as AbortSignal]) + : (input.signal ?? init.signal); + return fetch(requestUrl, { + ...init, + signal: combinedSignal, + }); } function hasZstdContentEncoding(contentEncoding: string | null): boolean { diff --git a/src/server/proxy-core/firstByteTimeout.test.ts b/src/server/proxy-core/firstByteTimeout.test.ts new file mode 100644 index 0000000..2fc93ca --- /dev/null +++ b/src/server/proxy-core/firstByteTimeout.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + fetchWithObservedFirstByte, + getObservedResponseMeta, + isObservedFirstByteTimeoutResponse, +} from './firstByteTimeout.js'; + +function buildDelayedResponse(bodyText: string, delayMs: number, status = 200): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { + controller.enqueue(encoder.encode(bodyText)); + controller.close(); + }, delayMs); + }, + }); + return new Response(body, { + status, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }); +} + +describe('fetchWithObservedFirstByte', () => { + it('replays the first chunk and records first-byte latency when upstream responds in time', async () => { + const response = await fetchWithObservedFirstByte( + async () => buildDelayedResponse('hello world', 5), + { + firstByteTimeoutMs: 100, + startedAtMs: Date.now(), + }, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('hello world'); + + const meta = getObservedResponseMeta(response); + expect(meta?.timedOutBeforeFirstByte).toBe(false); + expect(meta?.firstByteLatencyMs).not.toBeNull(); + expect((meta?.firstByteLatencyMs || 0)).toBeGreaterThanOrEqual(0); + expect((meta?.firstByteLatencyMs || 0)).toBeLessThan(100); + }); + + it('returns a synthetic timeout response when upstream sends no first byte before the deadline', async () => { + const response = await fetchWithObservedFirstByte( + async () => buildDelayedResponse('too late', 80), + { + firstByteTimeoutMs: 10, + startedAtMs: Date.now(), + }, + ); + + expect(response.status).toBe(408); + expect(await response.text()).toContain('first byte timeout'); + expect(isObservedFirstByteTimeoutResponse(response)).toBe(true); + + const meta = getObservedResponseMeta(response); + expect(meta?.timedOutBeforeFirstByte).toBe(true); + expect(meta?.firstByteLatencyMs).toBeNull(); + }); +}); diff --git a/src/server/proxy-core/firstByteTimeout.ts b/src/server/proxy-core/firstByteTimeout.ts new file mode 100644 index 0000000..540b738 --- /dev/null +++ b/src/server/proxy-core/firstByteTimeout.ts @@ -0,0 +1,179 @@ +import { Headers, Response } from 'undici'; + +type ObservedResponseMeta = { + firstByteLatencyMs: number | null; + timedOutBeforeFirstByte: boolean; +}; + +const observedResponseMeta = new WeakMap(); + +function setObservedResponseMeta(response: T, meta: ObservedResponseMeta): T { + observedResponseMeta.set(response, meta); + return response; +} + +function clearTimer(timer: ReturnType | null) { + if (!timer) return; + clearTimeout(timer); +} + +function buildFirstByteTimeoutMessage(timeoutMs: number): string { + const seconds = Math.max(1, Math.round(timeoutMs / 1000)); + return `first byte timeout (${seconds}s)`; +} + +function buildObservedTimeoutResponse(timeoutMs: number): Response { + return setObservedResponseMeta(new Response(buildFirstByteTimeoutMessage(timeoutMs), { + status: 408, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }), { + firstByteLatencyMs: null, + timedOutBeforeFirstByte: true, + }); +} + +async function cancelReaderQuietly(reader: ReadableStreamDefaultReader | null) { + if (!reader) return; + try { + await reader.cancel(); + } catch { + // Ignore cancellation errors from already-closed streams. + } + try { + reader.releaseLock(); + } catch { + // Ignore release errors from already-released readers. + } +} + +function buildReplayResponse( + response: T, + reader: ReadableStreamDefaultReader, + firstChunk: ReadableStreamReadResult, +): T { + let firstChunkDelivered = false; + let readerReleased = false; + const releaseReader = () => { + if (readerReleased) return; + readerReleased = true; + try { + reader.releaseLock(); + } catch { + // Ignore release failures from cancelled/closed readers. + } + }; + + const stream = new ReadableStream({ + async pull(controller) { + if (!firstChunkDelivered) { + firstChunkDelivered = true; + if (firstChunk.done) { + controller.close(); + releaseReader(); + return; + } + controller.enqueue(firstChunk.value); + return; + } + + try { + const next = await reader.read(); + if (next.done) { + controller.close(); + releaseReader(); + return; + } + controller.enqueue(next.value); + } catch (error) { + controller.error(error); + releaseReader(); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } catch { + // Ignore cancellation failures from already-closed streams. + } + releaseReader(); + }, + }); + + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: new Headers(response.headers), + }) as T; +} + +export async function fetchWithObservedFirstByte( + dispatch: (signal?: AbortSignal) => Promise, + options: { + firstByteTimeoutMs?: number; + startedAtMs?: number; + } = {}, +): Promise { + const timeoutMs = Math.max(0, Math.trunc(options.firstByteTimeoutMs ?? 0)); + const startedAtMs = options.startedAtMs ?? Date.now(); + const controller = timeoutMs > 0 ? new AbortController() : null; + let timer: ReturnType | null = null; + let timedOutBeforeFirstByte = false; + const timeoutSentinel = Symbol('first-byte-timeout'); + const timeoutPromise = timeoutMs > 0 + ? new Promise((resolve) => { + timer = setTimeout(() => { + timedOutBeforeFirstByte = true; + controller?.abort(new Error(buildFirstByteTimeoutMessage(timeoutMs))); + resolve(timeoutSentinel); + }, timeoutMs); + }) + : null; + + try { + const dispatched = timeoutPromise + ? await Promise.race([dispatch(controller?.signal), timeoutPromise]) + : await dispatch(controller?.signal); + if (dispatched === timeoutSentinel) { + return buildObservedTimeoutResponse(timeoutMs) as T; + } + const response = dispatched as T; + if (!response.body) { + clearTimer(timer); + return setObservedResponseMeta(response, { + firstByteLatencyMs: Math.max(0, Date.now() - startedAtMs), + timedOutBeforeFirstByte: false, + }); + } + + const reader = response.body.getReader() as ReadableStreamDefaultReader; + const firstChunk = timeoutPromise + ? await Promise.race([reader.read(), timeoutPromise]) + : await reader.read(); + if (firstChunk === timeoutSentinel) { + await cancelReaderQuietly(reader); + return buildObservedTimeoutResponse(timeoutMs) as T; + } + clearTimer(timer); + return setObservedResponseMeta(buildReplayResponse(response, reader, firstChunk), { + firstByteLatencyMs: Math.max(0, Date.now() - startedAtMs), + timedOutBeforeFirstByte: false, + }); + } catch (error) { + clearTimer(timer); + if (timedOutBeforeFirstByte && timeoutMs > 0) { + return buildObservedTimeoutResponse(timeoutMs) as T; + } + throw error; + } finally { + clearTimer(timer); + } +} + +export function getObservedResponseMeta(response: Response | null | undefined): ObservedResponseMeta | null { + if (!response) return null; + return observedResponseMeta.get(response) ?? null; +} + +export function isObservedFirstByteTimeoutResponse(response: Response | null | undefined): boolean { + return getObservedResponseMeta(response)?.timedOutBeforeFirstByte === true; +} diff --git a/src/server/proxy-core/orchestration/endpointFlow.ts b/src/server/proxy-core/orchestration/endpointFlow.ts index 30cad2e..b174443 100644 --- a/src/server/proxy-core/orchestration/endpointFlow.ts +++ b/src/server/proxy-core/orchestration/endpointFlow.ts @@ -1,5 +1,6 @@ import { fetch } from 'undici'; import { readRuntimeResponseText } from '../executors/types.js'; +import { fetchWithObservedFirstByte, isObservedFirstByteTimeoutResponse } from '../firstByteTimeout.js'; import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { buildUpstreamUrl, @@ -69,7 +70,9 @@ export type ExecuteEndpointFlowInput = { dispatchRequest?: ( request: BuiltEndpointRequest, targetUrl: string, + signal?: AbortSignal, ) => Promise>>; + firstByteTimeoutMs?: number; tryRecover?: (ctx: EndpointAttemptContext) => Promise; shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean; shouldAbortRemainingEndpoints?: (ctx: EndpointAttemptContext & { errText: string }) => boolean; @@ -117,13 +120,23 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom ? buildUpstreamUrl(input.proxyUrl, request.path) : defaultTarget; - let response = input.dispatchRequest - ? await input.dispatchRequest(request, targetUrl) - : await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { - method: 'POST', - headers: request.headers, - body: JSON.stringify(request.body), - })); + const attemptStartedAtMs = Date.now(); + let response = await fetchWithObservedFirstByte( + async (signal) => ( + input.dispatchRequest + ? await input.dispatchRequest(request, targetUrl, signal) + : await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(request.body), + signal, + })) + ), + { + firstByteTimeoutMs: input.firstByteTimeoutMs, + startedAtMs: attemptStartedAtMs, + }, + ); if (response.ok) { await runEndpointFlowHook(input.onAttemptSuccess, { @@ -151,6 +164,23 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom rawErrText, recoverApplied: false, }; + const isLastEndpoint = endpointIndex >= endpointCount - 1; + + if (isObservedFirstByteTimeoutResponse(response) && !isLastEndpoint) { + const errText = rawErrText.trim() || 'first byte timeout'; + const timeoutContext = { + ...baseContext, + errText, + }; + await runEndpointFlowHook(input.onAttemptFailure, timeoutContext, 'onAttemptFailure'); + finalStatus = response.status || 408; + finalErrText = errText; + finalRawErrText = rawErrText; + if (input.disableCrossProtocolFallback) { + break; + } + continue; + } if (input.tryRecover) { const recovered = await input.tryRecover(baseContext); @@ -192,7 +222,6 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom errText, }, 'onAttemptFailure'); - const isLastEndpoint = endpointIndex >= endpointCount - 1; if (input.disableCrossProtocolFallback && !isLastEndpoint) { finalStatus = response.status; finalErrText = errText; diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 6d198b0..a53435a 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -45,6 +45,7 @@ import { unwrapGeminiCliPayload, } from '../../routes/proxy/geminiCliCompat.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; +import { getObservedResponseMeta } from '../firstByteTimeout.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; @@ -394,6 +395,7 @@ export async function handleChatSurfaceRequest( return executeEndpointFlow({ siteUrl: siteApiBaseUrl, disableCrossProtocolFallback: config.disableCrossProtocolFallback, + firstByteTimeoutMs: Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, @@ -525,6 +527,7 @@ export async function handleChatSurfaceRequest( const upstream = endpointResult.upstream; const successfulUpstreamPath = endpointResult.upstreamPath; + const firstByteLatencyMs = getObservedResponseMeta(upstream)?.firstByteLatencyMs ?? null; if (isStream) { const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); @@ -557,6 +560,8 @@ export async function handleChatSurfaceRequest( parsedUsage, upstreamUsagePresent, requestStartedAtMs: startTime, + isStream: true, + firstByteLatencyMs, latencyMs, retryCount, upstreamPath: successfulUpstreamPath, @@ -918,6 +923,8 @@ export async function handleChatSurfaceRequest( parsedUsage, upstreamUsagePresent, requestStartedAtMs: startTime, + isStream: false, + firstByteLatencyMs, latencyMs: latency, retryCount, upstreamPath: successfulUpstreamPath, @@ -961,6 +968,7 @@ export async function handleChatSurfaceRequest( status: endpointFailureStatus || 502, errText: err.message || 'unknown error', rawErrText: err.rawErrText || err.message || 'unknown error', + isStream, latencyMs: Date.now() - startTime, retryCount, }); @@ -985,6 +993,7 @@ export async function handleChatSurfaceRequest( requestedModel, modelName, errorMessage: err?.message || 'network failure', + isStream, latencyMs: Date.now() - startTime, retryCount, }); @@ -1387,6 +1396,7 @@ export async function handleClaudeCountTokensSurfaceRequest( status: endpointFailureStatus || 502, errText: error.message || 'unknown error', rawErrText: error.rawErrText || error.message || 'unknown error', + isStream: false, latencyMs: Date.now() - startTime, retryCount, }); @@ -1407,6 +1417,7 @@ export async function handleClaudeCountTokensSurfaceRequest( requestedModel, modelName, errorMessage: error?.message || 'network failure', + isStream: false, latencyMs: Date.now() - startTime, retryCount, }); diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index 4addfdf..a245304 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -40,6 +40,7 @@ import { detectDownstreamClientContext, type DownstreamClientContext } from '../ import { insertProxyLog } from '../../services/proxyLogStore.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; +import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js'; import { @@ -253,6 +254,8 @@ async function logProxy( promptTokens = 0, completionTokens = 0, totalTokens = 0, + isStream = false, + firstByteLatencyMs: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -274,6 +277,8 @@ async function logProxy( modelActual: selected.actualModel || modelRequested, status, httpStatus, + isStream, + firstByteLatencyMs, latencyMs, promptTokens, completionTokens, @@ -611,6 +616,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); const startTime = Date.now(); + const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)); let upstreamPath = ''; try { @@ -698,11 +704,12 @@ export async function geminiProxyRoute(app: FastifyInstance) { requestHeaders, targetUrl, runtimeExecutor, - dispatch: () => ( + dispatch: (signal?: AbortSignal) => ( isInternalGemini ? dispatchRuntimeRequest({ siteUrl: selected.site.url, targetUrl, + signal, request: { endpoint: 'chat', path: upstreamPath, @@ -728,13 +735,22 @@ export async function geminiProxyRoute(app: FastifyInstance) { method: 'POST', headers: requestHeaders, body: JSON.stringify(requestBody), + signal, }) ), }; }; let directDispatchState = buildDirectDispatchState(); - let upstream = await directDispatchState.dispatch(); + const dispatchWithObservedFirstByte = async () => fetchWithObservedFirstByte( + (signal) => directDispatchState.dispatch(signal), + { + firstByteTimeoutMs, + startedAtMs: Date.now(), + }, + ); + let upstream = await dispatchWithObservedFirstByte(); + let firstByteLatencyMs = getObservedResponseMeta(upstream)?.firstByteLatencyMs ?? null; let contentType = upstream.headers.get('content-type') || 'application/json'; let recoverApplied = false; if (upstream.status === 401 && oauth) { @@ -748,7 +764,8 @@ export async function geminiProxyRoute(app: FastifyInstance) { }; oauth = getOauthInfoFromAccount(selected.account); directDispatchState = buildDirectDispatchState(); - upstream = await directDispatchState.dispatch(); + upstream = await dispatchWithObservedFirstByte(); + firstByteLatencyMs = getObservedResponseMeta(upstream)?.firstByteLatencyMs ?? null; contentType = upstream.headers.get('content-type') || 'application/json'; recoverApplied = true; } catch { @@ -791,6 +808,11 @@ export async function geminiProxyRoute(app: FastifyInstance) { downstreamPath, upstreamPath, clientContext, + 0, + 0, + 0, + isStreamAction, + firstByteLatencyMs, ); if (canRetryChannelSelection(retryCount, forcedChannelId)) { retryCount += 1; @@ -829,6 +851,11 @@ export async function geminiProxyRoute(app: FastifyInstance) { downstreamPath, upstreamPath, clientContext, + 0, + 0, + 0, + isStreamAction, + firstByteLatencyMs, ); await safeInsertSurfaceProxyDebugAttempt(debugTrace, { attemptIndex: retryCount, @@ -913,6 +940,8 @@ export async function geminiProxyRoute(app: FastifyInstance) { parsedUsage.promptTokens, parsedUsage.completionTokens, parsedUsage.totalTokens, + isStreamAction, + firstByteLatencyMs, ); await safeInsertSurfaceProxyDebugAttempt(debugTrace, { attemptIndex: retryCount, @@ -965,6 +994,8 @@ export async function geminiProxyRoute(app: FastifyInstance) { parsedUsage.promptTokens, parsedUsage.completionTokens, parsedUsage.totalTokens, + isStreamAction, + firstByteLatencyMs, ); await safeInsertSurfaceProxyDebugAttempt(debugTrace, { attemptIndex: retryCount, @@ -1029,6 +1060,8 @@ export async function geminiProxyRoute(app: FastifyInstance) { parsedUsage.promptTokens, parsedUsage.completionTokens, parsedUsage.totalTokens, + isStreamAction, + firstByteLatencyMs, ); await safeInsertSurfaceProxyDebugAttempt(debugTrace, { attemptIndex: retryCount, @@ -1074,6 +1107,11 @@ export async function geminiProxyRoute(app: FastifyInstance) { downstreamPath, upstreamPath, clientContext, + 0, + 0, + 0, + isStreamAction, + firstByteLatencyMs, ); await safeInsertSurfaceProxyDebugAttempt(debugTrace, { attemptIndex: retryCount, @@ -1192,10 +1230,15 @@ export async function geminiProxyRoute(app: FastifyInstance) { }; }; const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig); - const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( + const dispatchRequest = ( + compatibilityRequest: BuiltEndpointRequest, + targetUrl?: string, + signal?: AbortSignal, + ) => ( dispatchRuntimeRequest({ siteUrl: selected.site.url, targetUrl, + signal, request: compatibilityRequest, buildInit: async (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { method: 'POST', @@ -1221,6 +1264,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, disableCrossProtocolFallback: config.disableCrossProtocolFallback, + firstByteTimeoutMs, endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, @@ -1311,6 +1355,11 @@ export async function geminiProxyRoute(app: FastifyInstance) { downstreamPath, null, clientContext, + 0, + 0, + 0, + isStreamAction, + null, ); if (canRetryChannelSelection(retryCount, forcedChannelId)) { retryCount += 1; @@ -1324,6 +1373,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { upstreamPath = endpointResult.upstreamPath; const upstream = endpointResult.upstream; + const firstByteLatencyMs = getObservedResponseMeta(upstream)?.firstByteLatencyMs ?? null; const rawText = await readRuntimeResponseText(upstream); let upstreamData: unknown = rawText; try { @@ -1355,6 +1405,8 @@ export async function geminiProxyRoute(app: FastifyInstance) { parsedUsage.promptTokens, parsedUsage.completionTokens, parsedUsage.totalTokens, + isStreamAction, + firstByteLatencyMs, ); const downstreamPayload = isGeminiCliDownstream ? { response: geminiResponse } @@ -1397,6 +1449,11 @@ export async function geminiProxyRoute(app: FastifyInstance) { downstreamPath, upstreamPath || null, clientContext, + 0, + 0, + 0, + isStreamAction, + null, ); if (canRetryChannelSelection(retryCount, forcedChannelId)) { retryCount += 1; diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 72df039..fdf5e92 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -44,6 +44,7 @@ import { unwrapGeminiCliPayload, } from '../../routes/proxy/geminiCliCompat.js'; import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js'; +import { getObservedResponseMeta } from '../firstByteTimeout.js'; import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js'; import { runCodexHttpSessionTask } from '../runtime/codexHttpSessionQueue.js'; import { @@ -580,6 +581,7 @@ export async function handleOpenAiResponsesSurfaceRequest( return executeEndpointFlow({ siteUrl: siteApiBaseUrl, disableCrossProtocolFallback: config.disableCrossProtocolFallback, + firstByteTimeoutMs: Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, @@ -712,6 +714,7 @@ export async function handleOpenAiResponsesSurfaceRequest( const upstream = endpointResult.upstream; const successfulUpstreamPath = endpointResult.upstreamPath; + const firstByteLatencyMs = getObservedResponseMeta(upstream)?.firstByteLatencyMs ?? null; const finalizeStreamSuccess = async ( parsedUsage: UsageSummary, latency: number, @@ -726,6 +729,8 @@ export async function handleOpenAiResponsesSurfaceRequest( parsedUsage, upstreamUsagePresent, requestStartedAtMs: startTime, + isStream: true, + firstByteLatencyMs, latencyMs: latency, retryCount, upstreamPath: successfulUpstreamPath, @@ -1174,6 +1179,8 @@ export async function handleOpenAiResponsesSurfaceRequest( parsedUsage, upstreamUsagePresent, requestStartedAtMs: startTime, + isStream: false, + firstByteLatencyMs, latencyMs: latency, retryCount, upstreamPath: successfulUpstreamPath, @@ -1219,6 +1226,7 @@ export async function handleOpenAiResponsesSurfaceRequest( status: endpointFailureStatus || 502, errText: err?.message || 'unknown error', rawErrText: err?.rawErrText || err?.message || 'unknown error', + isStream, latencyMs: Date.now() - startTime, retryCount, }); @@ -1243,6 +1251,7 @@ export async function handleOpenAiResponsesSurfaceRequest( requestedModel, modelName, errorMessage: err?.message || 'network failure', + isStream, latencyMs: Date.now() - startTime, retryCount, }); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index ae6323d..927b3b4 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -191,6 +191,8 @@ export async function writeSurfaceProxyLog(input: { modelRequested: string; status: string; httpStatus: number; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; errorMessage: string | null; retryCount: number; @@ -227,6 +229,8 @@ export async function writeSurfaceProxyLog(input: { modelActual: input.selected.actualModel ?? null, status: input.status, httpStatus: input.httpStatus, + isStream: input.isStream ?? null, + firstByteLatencyMs: input.firstByteLatencyMs ?? null, latencyMs: input.latencyMs, promptTokens: input.promptTokens ?? null, completionTokens: input.completionTokens ?? null, @@ -255,10 +259,12 @@ export function createSurfaceDispatchRequest(input: { return ( request: BuiltEndpointRequest, targetUrl?: string, - ) => ( + signal?: AbortSignal, + ) => ( dispatchRuntimeRequest({ siteUrl: input.siteUrl ?? input.site.url, targetUrl, + signal, request, buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(input.site, { method: 'POST', @@ -326,6 +332,8 @@ export async function recordSurfaceSuccess(input: { parsedUsage: SurfaceUsageSummary; upstreamUsagePresent?: boolean; requestStartedAtMs: number; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; retryCount: number; upstreamPath?: string | null; @@ -334,6 +342,8 @@ export async function recordSurfaceSuccess(input: { modelRequested: string; status: string; httpStatus: number; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; errorMessage: string | null; retryCount: number; @@ -427,6 +437,8 @@ export async function recordSurfaceSuccess(input: { modelRequested: input.requestedModel, status: 'success', httpStatus: 200, + isStream: input.isStream ?? null, + firstByteLatencyMs: input.firstByteLatencyMs ?? null, latencyMs: input.latencyMs, errorMessage: null, retryCount: input.retryCount, @@ -458,6 +470,8 @@ export function createSurfaceFailureToolkit(input: { modelRequested: string; status: string; httpStatus: number; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; errorMessage: string | null; retryCount: number; @@ -474,6 +488,8 @@ export function createSurfaceFailureToolkit(input: { modelRequested: args.modelRequested, status: args.status, httpStatus: args.httpStatus, + isStream: args.isStream ?? null, + firstByteLatencyMs: args.firstByteLatencyMs ?? null, latencyMs: args.latencyMs, errorMessage: args.errorMessage, retryCount: args.retryCount, @@ -510,6 +526,8 @@ export function createSurfaceFailureToolkit(input: { status: number; errText: string; rawErrText?: string | null; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; retryCount: number; }): Promise { @@ -524,6 +542,8 @@ export function createSurfaceFailureToolkit(input: { modelRequested: args.requestedModel, status: 'failed', httpStatus: args.status, + isStream: args.isStream ?? null, + firstByteLatencyMs: args.firstByteLatencyMs ?? null, latencyMs: args.latencyMs, errorMessage: args.errText, retryCount: args.retryCount, @@ -570,6 +590,8 @@ export function createSurfaceFailureToolkit(input: { requestedModel: string; modelName: string; failure: { status: number; reason: string }; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; retryCount: number; promptTokens?: number | null; @@ -587,6 +609,8 @@ export function createSurfaceFailureToolkit(input: { modelRequested: args.requestedModel, status: 'failed', httpStatus: args.failure.status, + isStream: args.isStream ?? null, + firstByteLatencyMs: args.firstByteLatencyMs ?? null, latencyMs: args.latencyMs, errorMessage: args.failure.reason, retryCount: args.retryCount, @@ -623,6 +647,8 @@ export function createSurfaceFailureToolkit(input: { requestedModel: string; modelName: string; errorMessage: string; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; retryCount: number; }): Promise { @@ -635,6 +661,8 @@ export function createSurfaceFailureToolkit(input: { modelRequested: args.requestedModel, status: 'failed', httpStatus: 0, + isStream: args.isStream ?? null, + firstByteLatencyMs: args.firstByteLatencyMs ?? null, latencyMs: args.latencyMs, errorMessage: args.errorMessage, retryCount: args.retryCount, @@ -665,6 +693,8 @@ export function createSurfaceFailureToolkit(input: { requestedModel: string; modelName: string; errorMessage: string | null; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs: number; retryCount: number; promptTokens?: number | null; @@ -692,6 +722,8 @@ export function createSurfaceFailureToolkit(input: { modelRequested: args.requestedModel, status: 'failed', httpStatus: args.httpStatus ?? 200, + isStream: args.isStream ?? null, + firstByteLatencyMs: args.firstByteLatencyMs ?? null, latencyMs: args.latencyMs, errorMessage, retryCount: args.retryCount, diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index c922e75..e853f21 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -65,6 +65,7 @@ describe('settings and auth events', () => { (config as any).proxyDebugRetentionHours = 24; (config as any).proxyDebugMaxBodyBytes = 262144; config.routingFallbackUnitCost = 1; + (config as any).proxyFirstByteTimeoutSec = 0; (config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60; (config as any).disableCrossProtocolFallback = false; (config as any).telegramEnabled = false; @@ -508,6 +509,32 @@ describe('settings and auth events', () => { expect(saved?.value).toBe(JSON.stringify(thirtyDaysSec)); }); + it('persists and returns first-byte timeout from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + proxyFirstByteTimeoutSec: 7, + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { proxyFirstByteTimeoutSec?: number }; + expect(updated.proxyFirstByteTimeoutSec).toBe(7); + expect((config as any).proxyFirstByteTimeoutSec).toBe(7); + + const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'proxy_first_byte_timeout_sec')).get(); + expect(saved?.value).toBe(JSON.stringify(7)); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { proxyFirstByteTimeoutSec?: number }; + expect(runtime.proxyFirstByteTimeoutSec).toBe(7); + }); + it('persists and returns disable cross protocol fallback from runtime settings', async () => { const updateResponse = await app.inject({ method: 'PUT', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 6ae95da..aa60231 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -95,6 +95,7 @@ interface RuntimeSettingsBody { notifyCooldownSec?: number; adminIpAllowlist?: string[] | string; routingFallbackUnitCost?: number; + proxyFirstByteTimeoutSec?: number; tokenRouterFailureCooldownMaxSec?: number; routingWeights?: Partial; proxyErrorKeywords?: string[] | string; @@ -681,6 +682,12 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { config.routingFallbackUnitCost = Math.max(1e-6, n); return; } + case 'proxy_first_byte_timeout_sec': { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return; + config.proxyFirstByteTimeoutSec = Math.max(0, Math.trunc(n)); + return; + } case 'token_router_failure_cooldown_max_sec': { const normalized = normalizeTokenRouterFailureCooldownMaxSec(value); if (normalized == null) return; @@ -717,6 +724,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { proxyDebugRetentionHours: config.proxyDebugRetentionHours, proxyDebugMaxBodyBytes: config.proxyDebugMaxBodyBytes, routingFallbackUnitCost: config.routingFallbackUnitCost, + proxyFirstByteTimeoutSec: config.proxyFirstByteTimeoutSec, tokenRouterFailureCooldownMaxSec: config.tokenRouterFailureCooldownMaxSec, routingWeights: config.routingWeights, webhookUrl: config.webhookUrl, @@ -1639,6 +1647,19 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('routing_fallback_unit_cost', normalized); } + if (body.proxyFirstByteTimeoutSec !== undefined) { + const nextProxyFirstByteTimeoutSec = Number(body.proxyFirstByteTimeoutSec); + if (!Number.isFinite(nextProxyFirstByteTimeoutSec) || nextProxyFirstByteTimeoutSec < 0) { + return reply.code(400).send({ success: false, message: '首字超时必须是大于等于 0 的数字(秒)' }); + } + const normalized = Math.max(0, Math.trunc(nextProxyFirstByteTimeoutSec)); + if (normalized !== config.proxyFirstByteTimeoutSec) { + changedLabels.push(`首字超时(${config.proxyFirstByteTimeoutSec}s -> ${normalized}s)`); + } + config.proxyFirstByteTimeoutSec = normalized; + upsertSetting('proxy_first_byte_timeout_sec', normalized); + } + if (body.tokenRouterFailureCooldownMaxSec !== undefined) { const normalized = normalizeTokenRouterFailureCooldownMaxSec(body.tokenRouterFailureCooldownMaxSec); if (normalized == null) { diff --git a/src/server/routes/api/stats.proxy-logs.test.ts b/src/server/routes/api/stats.proxy-logs.test.ts index 1bd8963..8d617b6 100644 --- a/src/server/routes/api/stats.proxy-logs.test.ts +++ b/src/server/routes/api/stats.proxy-logs.test.ts @@ -75,6 +75,8 @@ describe('stats proxy logs routes', () => { modelRequested: 'gpt-4o', modelActual: 'gpt-4o', status: 'success', + isStream: 1, + firstByteLatencyMs: 45, clientFamily: 'generic', clientAppId: 'cherry_studio', clientAppName: 'Cherry Studio', @@ -92,6 +94,8 @@ describe('stats proxy logs routes', () => { modelRequested: 'gpt-4o-mini', modelActual: 'gpt-4o-mini', status: 'failed', + isStream: 0, + firstByteLatencyMs: 12, clientFamily: 'codex', promptTokens: 8, completionTokens: 2, @@ -105,6 +109,8 @@ describe('stats proxy logs routes', () => { modelRequested: 'gpt-4.1', modelActual: 'gpt-4.1', status: 'retried', + isStream: 1, + firstByteLatencyMs: 30, promptTokens: 20, completionTokens: 4, totalTokens: 24, @@ -117,6 +123,8 @@ describe('stats proxy logs routes', () => { modelRequested: 'claude-3-7-sonnet', modelActual: 'claude-3-7-sonnet', status: 'success', + isStream: 1, + firstByteLatencyMs: 88, promptTokens: 40, completionTokens: 10, totalTokens: 50, @@ -163,6 +171,8 @@ describe('stats proxy logs routes', () => { expect(body.items[0]?.clientAppId).toBe(null); expect(body.items[0]?.clientAppName).toBe(null); expect(body.items[0]?.clientConfidence).toBe(null); + expect(body.items[0]?.isStream).toBe(false); + expect(body.items[0]?.firstByteLatencyMs).toBe(12); expect(body.items[0]).not.toHaveProperty('billingDetails'); expect(body.clientOptions).toEqual([ { value: 'family:codex', label: '协议 · Codex' }, @@ -204,6 +214,8 @@ describe('stats proxy logs routes', () => { modelRequested: 'gpt-5', modelActual: 'gpt-5', status: 'success', + isStream: 1, + firstByteLatencyMs: 64, clientFamily: 'codex', clientAppId: 'cherry_studio', clientAppName: 'Cherry Studio', @@ -238,6 +250,8 @@ describe('stats proxy logs routes', () => { clientAppId: string | null; clientAppName: string | null; clientConfidence: string | null; + isStream: boolean | null; + firstByteLatencyMs: number | null; billingDetails: Record | null; }; @@ -251,6 +265,8 @@ describe('stats proxy logs routes', () => { expect(body.clientAppId).toBe('cherry_studio'); expect(body.clientAppName).toBe('Cherry Studio'); expect(body.clientConfidence).toBe('exact'); + expect(body.isStream).toBe(true); + expect(body.firstByteLatencyMs).toBe(64); expect(body.billingDetails).toMatchObject({ breakdown: { totalCost: 0.12 }, usage: { promptTokens: 100, completionTokens: 20 }, diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index b153d2a..4325188 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -497,6 +497,10 @@ function mapProxyLogRow( const legacyMeta = parseProxyLogMessageMeta(typeof row.proxy_logs.errorMessage === 'string' ? row.proxy_logs.errorMessage : ''); return { ...row.proxy_logs, + isStream: row.proxy_logs.isStream == null ? null : Boolean(row.proxy_logs.isStream), + firstByteLatencyMs: typeof row.proxy_logs.firstByteLatencyMs === 'number' + ? row.proxy_logs.firstByteLatencyMs + : null, ...(options?.includeBillingDetails ? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) } : {}), diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index a4bd54f..bec7795 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; +import { config } from '../../config.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; @@ -17,6 +18,7 @@ import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js'; import { @@ -46,8 +48,9 @@ export async function completionsProxyRoute(app: FastifyInstance) { headers: request.headers as Record, body, }); - - const isStream = body.stream === true; + + const isStream = body.stream === true; + const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -77,23 +80,37 @@ export async function completionsProxyRoute(app: FastifyInstance) { const forwardBody = { ...body, model: upstreamModel }; const startTime = Date.now(); try { - const upstream = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const { upstream, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const attemptStartedAtMs = Date.now(); const targetUrl = buildUpstreamUrl(target.baseUrl, '/v1/completions'); - const response = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, + const response = await fetchWithObservedFirstByte( + async (signal) => fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + signal, + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))), + { + firstByteTimeoutMs, + startedAtMs: attemptStartedAtMs, }, - body: JSON.stringify(forwardBody), - }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); + ); + const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null; if (!response.ok) { const errText = await response.text().catch(() => 'unknown error'); throw new SiteApiEndpointRequestError(errText || 'unknown error', { status: response.status, + rawErrText: errText || null, + firstByteLatencyMs: observedFirstByteLatencyMs, }); } - return response; + return { + upstream: response, + firstByteLatencyMs: observedFirstByteLatencyMs, + }; }); if (isStream) { @@ -191,9 +208,11 @@ export async function completionsProxyRoute(app: FastifyInstance) { billingDetails, clientContext, downstreamPath, + isStream, + firstByteLatencyMs, ); - return; - } + return; + } const rawText = await upstream.text(); let data: any = rawText; @@ -228,6 +247,8 @@ export async function completionsProxyRoute(app: FastifyInstance) { null, clientContext, downstreamPath, + isStream, + firstByteLatencyMs, ); if (shouldRetryProxyRequest(failure.status, errText) && canRetryChannelSelection(retryCount, forcedChannelId)) { @@ -288,11 +309,14 @@ export async function completionsProxyRoute(app: FastifyInstance) { billingDetails, clientContext, downstreamPath, + isStream, + firstByteLatencyMs, ); return reply.send(data); } catch (err: any) { const status = err instanceof SiteApiEndpointRequestError ? (err.status || 0) : 0; const errorText = err?.message || 'network failure'; + const firstByteLatencyMs = err instanceof SiteApiEndpointRequestError ? err.firstByteLatencyMs : null; await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { status, errorText, @@ -314,6 +338,8 @@ export async function completionsProxyRoute(app: FastifyInstance) { null, clientContext, downstreamPath, + isStream, + firstByteLatencyMs, ); if (status > 0 && isTokenExpiredError({ status, message: errorText })) { await reportTokenExpired({ @@ -355,6 +381,8 @@ async function logProxy( billingDetails: unknown = null, clientContext: DownstreamClientContext | null = null, downstreamPath = '/v1/completions', + isStream: boolean, + firstByteLatencyMs: number | null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -375,9 +403,11 @@ async function logProxy( modelRequested, modelActual: selected.actualModel || modelRequested, status, - httpStatus, - latencyMs, - promptTokens, + httpStatus, + isStream, + firstByteLatencyMs, + latencyMs, + promptTokens, completionTokens, totalTokens, estimatedCost, diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 57551cd..35a361a 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; +import { config } from '../../config.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; @@ -16,6 +17,7 @@ import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js'; import { @@ -40,12 +42,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { }); const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; const downstreamPath = '/v1/embeddings'; - const clientContext = detectDownstreamClientContext({ - downstreamPath, - headers: request.headers as Record, - body, - }); - + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body, + }); + const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)); + const excludeChannelIds: number[] = []; let retryCount = 0; @@ -71,16 +74,25 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { const forwardBody = { ...body, model: upstreamModel }; const startTime = Date.now(); try { - const { upstream, text } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const { upstream, text, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const attemptStartedAtMs = Date.now(); const targetUrl = buildUpstreamUrl(target.baseUrl, '/v1/embeddings'); - const response = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, + const response = await fetchWithObservedFirstByte( + async (signal) => fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + signal, + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))), + { + firstByteTimeoutMs, + startedAtMs: attemptStartedAtMs, }, - body: JSON.stringify(forwardBody), - }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); + ); + const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null; const status = response.status; let responseText = ''; try { @@ -89,6 +101,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { if (!response.ok) { throw new SiteApiEndpointRequestError('unknown error', { status, + firstByteLatencyMs: observedFirstByteLatencyMs, cause: error, }); } @@ -98,11 +111,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { throw new SiteApiEndpointRequestError(responseText || 'unknown error', { status, rawErrText: responseText || null, + firstByteLatencyMs: observedFirstByteLatencyMs, }); } return { upstream: response, text: responseText, + firstByteLatencyMs: observedFirstByteLatencyMs, }; }); @@ -140,11 +155,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { logProxy( selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath, + false, firstByteLatencyMs, ); return reply.code(upstream.status).send(data); } catch (err: any) { const status = err instanceof SiteApiEndpointRequestError ? (err.status || 0) : 0; const errorText = err?.message || 'network failure'; + const firstByteLatencyMs = err instanceof SiteApiEndpointRequestError ? err.firstByteLatencyMs : null; await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { status, errorText, @@ -166,6 +183,8 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { null, clientContext, downstreamPath, + false, + firstByteLatencyMs, ); if (status > 0 && isTokenExpiredError({ status, message: errorText })) { await reportTokenExpired({ @@ -206,11 +225,13 @@ async function logProxy( promptTokens = 0, completionTokens = 0, totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - clientContext: DownstreamClientContext | null = null, - downstreamPath = '/v1/embeddings', -) { + estimatedCost = 0, + billingDetails: unknown = null, + clientContext: DownstreamClientContext | null = null, + downstreamPath = '/v1/embeddings', + isStream = false, + firstByteLatencyMs: number | null = null, +) { try { const createdAt = formatUtcSqlDateTime(new Date()); const normalizedErrorMessage = composeProxyLogMessage({ @@ -229,11 +250,13 @@ async function logProxy( downstreamApiKeyId, modelRequested, modelActual: selected.actualModel || modelRequested, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, + status, + httpStatus, + isStream, + firstByteLatencyMs, + latencyMs, + promptTokens, + completionTokens, totalTokens, estimatedCost, billingDetails, diff --git a/src/server/routes/proxy/endpointFlow.first-byte-timeout.test.ts b/src/server/routes/proxy/endpointFlow.first-byte-timeout.test.ts new file mode 100644 index 0000000..1192898 --- /dev/null +++ b/src/server/routes/proxy/endpointFlow.first-byte-timeout.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import type { BuiltEndpointRequest } from './endpointFlow.js'; + +function requestFor(path: string): BuiltEndpointRequest { + return { + endpoint: 'responses', + path, + headers: { 'content-type': 'application/json' }, + body: { model: 'gpt-5.2', input: 'hello' }, + }; +} + +function buildDelayedResponse( + bodyText: string, + delayMs: number, + status = 200, + _signal?: AbortSignal, +): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { + controller.enqueue(encoder.encode(bodyText)); + controller.close(); + }, delayMs); + }, + }); + return new Response(body, { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('executeEndpointFlow first-byte timeout', () => { + it('falls through to the next endpoint candidate when the current endpoint times out before any output', async () => { + const { executeEndpointFlow } = await import('./endpointFlow.js'); + let timedOutSignal: AbortSignal | undefined; + const dispatchRequest = async ( + request: BuiltEndpointRequest, + _targetUrl?: string, + signal?: AbortSignal, + ) => ( + request.path === '/v1/responses' + ? ( + timedOutSignal = signal, + buildDelayedResponse(JSON.stringify({ ok: false }), 60, 200, signal) + ) + : new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as unknown as Awaited>; + + const failures: string[] = []; + const result = await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses', 'chat'], + buildRequest: (endpoint: 'responses' | 'chat') => endpoint === 'responses' + ? requestFor('/v1/responses') + : { ...requestFor('/v1/chat/completions'), endpoint }, + dispatchRequest, + firstByteTimeoutMs: 10, + onAttemptFailure: (ctx: { errText: string }) => { + failures.push(ctx.errText); + }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.upstreamPath).toBe('/v1/chat/completions'); + } + expect(timedOutSignal).toBeDefined(); + expect(timedOutSignal?.aborted).toBe(true); + expect(failures).toHaveLength(1); + expect(failures[0]).toContain('first byte timeout'); + }); + + it('treats first-byte timeout as terminal when cross-protocol fallback is disabled', async () => { + const { executeEndpointFlow } = await import('./endpointFlow.js'); + const attemptedPaths: string[] = []; + const result = await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses', 'chat'], + disableCrossProtocolFallback: true, + buildRequest: (endpoint: 'responses' | 'chat') => endpoint === 'responses' + ? requestFor('/v1/responses') + : { ...requestFor('/v1/chat/completions'), endpoint }, + dispatchRequest: async ( + request: BuiltEndpointRequest, + _targetUrl?: string, + signal?: AbortSignal, + ) => { + attemptedPaths.push(request.path); + return ( + request.path === '/v1/responses' + ? buildDelayedResponse(JSON.stringify({ ok: false }), 60, 200, signal) + : new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as unknown as Awaited>; + }, + firstByteTimeoutMs: 10, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(408); + expect(result.errText).toContain('first byte timeout'); + } + expect(attemptedPaths).toEqual(['/v1/responses']); + }); +}); diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 0267ae7..056ed36 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetch } from 'undici'; import type { BuiltEndpointRequest } from './endpointFlow.js'; -vi.mock('undici', () => ({ - fetch: vi.fn(), -})); +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: vi.fn(), + }; +}); vi.mock('../../services/siteProxy.js', () => ({ withSiteProxyRequestInit: async (_targetUrl: string, init: RequestInit) => init, diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 40a9c48..96093ee 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; +import { config } from '../../config.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; @@ -15,6 +16,7 @@ import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js'; import { @@ -43,6 +45,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { headers: request.headers as Record, body, }); + const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -72,25 +75,37 @@ export async function imagesProxyRoute(app: FastifyInstance) { const startTime = Date.now(); try { - const { upstream, text } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const { upstream, text, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const attemptStartedAtMs = Date.now(); const targetUrl = buildUpstreamUrl(target.baseUrl, '/v1/images/generations'); - const response = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${selected.tokenValue}`, + const response = await fetchWithObservedFirstByte( + async (signal) => fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + signal, + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))), + { + firstByteTimeoutMs, + startedAtMs: attemptStartedAtMs, }, - body: JSON.stringify(forwardBody), - }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); + ); + const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null; const responseText = await response.text(); if (!response.ok) { throw new SiteApiEndpointRequestError(responseText || 'unknown error', { status: response.status, + rawErrText: responseText || null, + firstByteLatencyMs: observedFirstByteLatencyMs, }); } return { upstream: response, text: responseText, + firstByteLatencyMs: observedFirstByteLatencyMs, }; }); @@ -113,6 +128,8 @@ export async function imagesProxyRoute(app: FastifyInstance) { 0, downstreamPath, clientContext, + false, + firstByteLatencyMs, ); if (canRetryChannelSelection(retryCount, forcedChannelId)) { retryCount++; @@ -145,11 +162,26 @@ export async function imagesProxyRoute(app: FastifyInstance) { await recordTokenRouterEventBestEffort('record downstream cost usage', () => ( recordDownstreamCostUsage(request, estimatedCost) )); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext); + logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamApiKeyId, + estimatedCost, + downstreamPath, + clientContext, + false, + firstByteLatencyMs, + ); return reply.code(upstream.status).send(data.value); } catch (err: any) { const status = err instanceof SiteApiEndpointRequestError ? (err.status || 0) : 0; const errorText = err?.message || 'network failure'; + const firstByteLatencyMs = err instanceof SiteApiEndpointRequestError ? err.firstByteLatencyMs : null; await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { status, errorText, @@ -167,6 +199,8 @@ export async function imagesProxyRoute(app: FastifyInstance) { 0, downstreamPath, clientContext, + false, + firstByteLatencyMs, ); if (status > 0 && isTokenExpiredError({ status, message: errorText })) { await reportTokenExpired({ @@ -216,6 +250,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { headers: request.headers as Record, body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []), }); + const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -244,7 +279,8 @@ export async function imagesProxyRoute(app: FastifyInstance) { const startTime = Date.now(); try { - const { upstream, text } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const { upstream, text, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const attemptStartedAtMs = Date.now(); const targetUrl = buildUpstreamUrl(target.baseUrl, '/v1/images/edits'); const requestInit = multipartForm ? withSiteRecordProxyRequestInit(selected.site, { @@ -267,16 +303,29 @@ export async function imagesProxyRoute(app: FastifyInstance) { model: upstreamModel, }), }, getProxyUrlFromExtraConfig(selected.account.extraConfig)); - const response = await fetch(targetUrl, requestInit); + const response = await fetchWithObservedFirstByte( + async (signal) => fetch(targetUrl, { + ...requestInit, + signal, + }), + { + firstByteTimeoutMs, + startedAtMs: attemptStartedAtMs, + }, + ); + const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null; const responseText = await response.text(); if (!response.ok) { throw new SiteApiEndpointRequestError(responseText || 'unknown error', { status: response.status, + rawErrText: responseText || null, + firstByteLatencyMs: observedFirstByteLatencyMs, }); } return { upstream: response, text: responseText, + firstByteLatencyMs: observedFirstByteLatencyMs, }; }); @@ -299,6 +348,8 @@ export async function imagesProxyRoute(app: FastifyInstance) { 0, downstreamPath, clientContext, + false, + firstByteLatencyMs, ); if (canRetryChannelSelection(retryCount, forcedChannelId)) { retryCount++; @@ -331,11 +382,26 @@ export async function imagesProxyRoute(app: FastifyInstance) { await recordTokenRouterEventBestEffort('record downstream cost usage', () => ( recordDownstreamCostUsage(request, estimatedCost) )); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext); + logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamApiKeyId, + estimatedCost, + downstreamPath, + clientContext, + false, + firstByteLatencyMs, + ); return reply.code(upstream.status).send(data.value); } catch (err: any) { const status = err instanceof SiteApiEndpointRequestError ? (err.status || 0) : 0; const errorText = err?.message || 'network failure'; + const firstByteLatencyMs = err instanceof SiteApiEndpointRequestError ? err.firstByteLatencyMs : null; await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { status, errorText, @@ -353,6 +419,8 @@ export async function imagesProxyRoute(app: FastifyInstance) { 0, downstreamPath, clientContext, + false, + firstByteLatencyMs, ); if (status > 0 && isTokenExpiredError({ status, message: errorText })) { await reportTokenExpired({ @@ -402,6 +470,8 @@ async function logProxy( estimatedCost = 0, downstreamPath = '/v1/images/generations', clientContext: DownstreamClientContext | null = null, + isStream = false, + firstByteLatencyMs: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -423,6 +493,8 @@ async function logProxy( modelActual: selected.actualModel || modelRequested, status, httpStatus, + isStream, + firstByteLatencyMs, latencyMs, promptTokens: 0, completionTokens: 0, diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index 038f6ea..e2a5594 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -1,5 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; +import { config } from '../../config.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; @@ -13,6 +14,7 @@ import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js'; import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js'; import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js'; import { @@ -71,6 +73,7 @@ export async function searchProxyRoute(app: FastifyInstance) { headers: request.headers as Record, body, }); + const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -104,25 +107,37 @@ export async function searchProxyRoute(app: FastifyInstance) { const startTime = Date.now(); try { - const { upstream, text } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const { upstream, text, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => { + const attemptStartedAtMs = Date.now(); const targetUrl = buildUpstreamUrl(target.baseUrl, '/v1/search'); - const response = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${selected.tokenValue}`, + const response = await fetchWithObservedFirstByte( + async (signal) => fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${selected.tokenValue}`, + }, + body: JSON.stringify(forwardBody), + signal, + }, getProxyUrlFromExtraConfig(selected.account.extraConfig))), + { + firstByteTimeoutMs, + startedAtMs: attemptStartedAtMs, }, - body: JSON.stringify(forwardBody), - }, getProxyUrlFromExtraConfig(selected.account.extraConfig))); + ); + const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null; const responseText = await response.text(); if (!response.ok) { throw new SiteApiEndpointRequestError(responseText || 'unknown error', { status: response.status, + rawErrText: responseText || null, + firstByteLatencyMs: observedFirstByteLatencyMs, }); } return { upstream: response, text: responseText, + firstByteLatencyMs: observedFirstByteLatencyMs, }; }); @@ -134,11 +149,25 @@ export async function searchProxyRoute(app: FastifyInstance) { tokenRouter.recordSuccess(selected.channel.id, latency, 0, upstreamModel) )); recordDownstreamCostUsage(request, 0); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath); + logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamApiKeyId, + clientContext, + downstreamPath, + false, + firstByteLatencyMs, + ); return reply.code(upstream.status).send(data); } catch (error: any) { const status = error instanceof SiteApiEndpointRequestError ? (error.status || 0) : 0; const errorText = error?.message || 'network error'; + const firstByteLatencyMs = error instanceof SiteApiEndpointRequestError ? error.firstByteLatencyMs : null; await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, { status, errorText, @@ -155,6 +184,8 @@ export async function searchProxyRoute(app: FastifyInstance) { downstreamApiKeyId, clientContext, downstreamPath, + false, + firstByteLatencyMs, ); if (status > 0 && isTokenExpiredError({ status, message: errorText })) { await reportTokenExpired({ @@ -194,6 +225,8 @@ async function logProxy( downstreamApiKeyId: number | null = null, clientContext: DownstreamClientContext | null = null, downstreamPath = '/v1/search', + isStream = false, + firstByteLatencyMs: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -206,6 +239,8 @@ async function logProxy( modelActual: selected.actualModel || modelRequested, status, httpStatus, + isStream, + firstByteLatencyMs, latencyMs, promptTokens: 0, completionTokens: 0, diff --git a/src/server/services/proxyLogStore.test.ts b/src/server/services/proxyLogStore.test.ts index 747530f..6dfdd73 100644 --- a/src/server/services/proxyLogStore.test.ts +++ b/src/server/services/proxyLogStore.test.ts @@ -4,6 +4,7 @@ const { hasProxyLogBillingDetailsColumnMock, hasProxyLogClientColumnsMock, hasProxyLogDownstreamApiKeyIdColumnMock, + hasProxyLogStreamTimingColumnsMock, dbInsertMock, dbInsertValuesMock, dbInsertRunMock, @@ -12,6 +13,7 @@ const { hasProxyLogBillingDetailsColumnMock: vi.fn(), hasProxyLogClientColumnsMock: vi.fn(), hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(), + hasProxyLogStreamTimingColumnsMock: vi.fn(), dbInsertMock: vi.fn(), dbInsertValuesMock: vi.fn(), dbInsertRunMock: vi.fn(), @@ -24,6 +26,8 @@ const { modelActual: 'model_actual', status: 'status', httpStatus: 'http_status', + isStream: 'is_stream', + firstByteLatencyMs: 'first_byte_latency_ms', latencyMs: 'latency_ms', promptTokens: 'prompt_tokens', completionTokens: 'completion_tokens', @@ -50,6 +54,7 @@ vi.mock('../db/index.js', () => ({ hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args), hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args), hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args), + hasProxyLogStreamTimingColumns: (...args: unknown[]) => hasProxyLogStreamTimingColumnsMock(...args), })); import { insertProxyLog, parseProxyLogBillingDetails, withProxyLogSelectFields } from './proxyLogStore.js'; @@ -59,12 +64,14 @@ describe('proxyLogStore', () => { hasProxyLogBillingDetailsColumnMock.mockReset(); hasProxyLogClientColumnsMock.mockReset(); hasProxyLogDownstreamApiKeyIdColumnMock.mockReset(); + hasProxyLogStreamTimingColumnsMock.mockReset(); dbInsertMock.mockReset(); dbInsertValuesMock.mockReset(); dbInsertRunMock.mockReset(); hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false); hasProxyLogClientColumnsMock.mockResolvedValue(false); hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false); + hasProxyLogStreamTimingColumnsMock.mockResolvedValue(false); dbInsertMock.mockReturnValue({ values: (...args: unknown[]) => dbInsertValuesMock(...args), @@ -89,6 +96,23 @@ describe('proxyLogStore', () => { expect(runner.mock.calls[1][0].fields.billingDetails).toBeUndefined(); }); + it('retries proxy log selects without stream timing fields when the columns are missing', async () => { + hasProxyLogStreamTimingColumnsMock.mockResolvedValue(true); + const runner = vi.fn() + .mockRejectedValueOnce(new Error('column proxy_logs.first_byte_latency_ms does not exist')) + .mockResolvedValueOnce([{ id: 1 }]); + + await expect(withProxyLogSelectFields(runner)).resolves.toEqual([{ id: 1 }]); + + expect(runner).toHaveBeenCalledTimes(2); + expect(runner.mock.calls[0][0].includeStreamTimingFields).toBe(true); + expect(runner.mock.calls[0][0].fields.isStream).toBe('is_stream'); + expect(runner.mock.calls[0][0].fields.firstByteLatencyMs).toBe('first_byte_latency_ms'); + expect(runner.mock.calls[1][0].includeStreamTimingFields).toBe(false); + expect(runner.mock.calls[1][0].fields.isStream).toBeUndefined(); + expect(runner.mock.calls[1][0].fields.firstByteLatencyMs).toBeUndefined(); + }); + it('accepts parsed billing details objects for helper-level callers', () => { expect(parseProxyLogBillingDetails({ source: 'pricing', @@ -220,4 +244,29 @@ describe('proxyLogStore', () => { expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined(); expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined(); }); + + it('retries proxy log inserts without stream timing fields when those columns are missing', async () => { + hasProxyLogStreamTimingColumnsMock.mockResolvedValue(true); + dbInsertRunMock + .mockRejectedValueOnce(new Error('column proxy_logs.is_stream does not exist')) + .mockResolvedValueOnce(undefined); + + await insertProxyLog({ + modelRequested: 'gpt-5', + isStream: true, + firstByteLatencyMs: 42, + }); + + expect(dbInsertValuesMock).toHaveBeenCalledTimes(2); + expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({ + modelRequested: 'gpt-5', + isStream: true, + firstByteLatencyMs: 42, + }); + expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({ + modelRequested: 'gpt-5', + }); + expect(dbInsertValuesMock.mock.calls[1][0].isStream).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[1][0].firstByteLatencyMs).toBeUndefined(); + }); }); diff --git a/src/server/services/proxyLogStore.ts b/src/server/services/proxyLogStore.ts index 9cf6c53..f486ec2 100644 --- a/src/server/services/proxyLogStore.ts +++ b/src/server/services/proxyLogStore.ts @@ -4,6 +4,7 @@ import { hasProxyLogBillingDetailsColumn, hasProxyLogClientColumns, hasProxyLogDownstreamApiKeyIdColumn, + hasProxyLogStreamTimingColumns, } from '../db/index.js'; export type ProxyLogInsertInput = { @@ -15,6 +16,8 @@ export type ProxyLogInsertInput = { modelActual?: string | null; status?: string | null; httpStatus?: number | null; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; latencyMs?: number | null; promptTokens?: number | null; completionTokens?: number | null; @@ -61,12 +64,21 @@ function buildProxyLogClientSelectFields() { }; } +function buildProxyLogStreamTimingSelectFields() { + return { + isStream: schema.proxyLogs.isStream, + firstByteLatencyMs: schema.proxyLogs.firstByteLatencyMs, + }; +} + function buildProxyLogSelectFields(options?: { includeBillingDetails?: boolean; includeClientFields?: boolean; + includeStreamTimingFields?: boolean; }) { return { ...buildProxyLogCoreSelectFields(), + ...(options?.includeStreamTimingFields ? buildProxyLogStreamTimingSelectFields() : {}), ...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}), ...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}), }; @@ -81,31 +93,37 @@ export type ProxyLogSelectFields = ReturnType; export type ResolvedProxyLogSelectFields = { includeBillingDetails: boolean; includeClientFields: boolean; + includeStreamTimingFields: boolean; fields: ProxyLogSelectFields; }; export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean; includeClientFields?: boolean; + includeStreamTimingFields?: boolean; }) { const includeBillingDetails = options?.includeBillingDetails === true && await hasProxyLogBillingDetailsColumn(); const includeClientFields = options?.includeClientFields !== false && await hasProxyLogClientColumns(); + const includeStreamTimingFields = options?.includeStreamTimingFields !== false + && await hasProxyLogStreamTimingColumns(); return { includeBillingDetails, includeClientFields, + includeStreamTimingFields, fields: buildProxyLogSelectFields({ includeBillingDetails, includeClientFields, + includeStreamTimingFields, }), }; } export async function withProxyLogSelectFields( runner: (selection: ResolvedProxyLogSelectFields) => Promise, - options?: { includeBillingDetails?: boolean; includeClientFields?: boolean }, + options?: { includeBillingDetails?: boolean; includeClientFields?: boolean; includeStreamTimingFields?: boolean }, ): Promise { let selection = await resolveProxyLogSelectFields(options); @@ -117,9 +135,11 @@ export async function withProxyLogSelectFields( selection = { includeBillingDetails: false, includeClientFields: selection.includeClientFields, + includeStreamTimingFields: selection.includeStreamTimingFields, fields: buildProxyLogSelectFields({ includeBillingDetails: false, includeClientFields: selection.includeClientFields, + includeStreamTimingFields: selection.includeStreamTimingFields, }), }; continue; @@ -129,9 +149,25 @@ export async function withProxyLogSelectFields( selection = { includeBillingDetails: selection.includeBillingDetails, includeClientFields: false, + includeStreamTimingFields: selection.includeStreamTimingFields, fields: buildProxyLogSelectFields({ includeBillingDetails: selection.includeBillingDetails, includeClientFields: false, + includeStreamTimingFields: selection.includeStreamTimingFields, + }), + }; + continue; + } + + if (selection.includeStreamTimingFields && isMissingProxyLogStreamTimingColumnsError(error)) { + selection = { + includeBillingDetails: selection.includeBillingDetails, + includeClientFields: selection.includeClientFields, + includeStreamTimingFields: false, + fields: buildProxyLogSelectFields({ + includeBillingDetails: selection.includeBillingDetails, + includeClientFields: selection.includeClientFields, + includeStreamTimingFields: false, }), }; continue; @@ -204,6 +240,22 @@ export function isMissingProxyLogClientColumnsError(error: unknown): boolean { ); } +export function isMissingProxyLogStreamTimingColumnsError(error: unknown): boolean { + const lowered = normalizeProxyLogStoreErrorMessage(error); + const hasStreamTimingColumnReference = [ + 'is_stream', + 'first_byte_latency_ms', + ].some((columnName) => lowered.includes(columnName)); + + return hasStreamTimingColumnReference + && ( + lowered.includes('does not exist') + || lowered.includes('unknown column') + || lowered.includes('no such column') + || lowered.includes('has no column named') + ); +} + export async function insertProxyLog(input: ProxyLogInsertInput): Promise { const baseValues = { routeId: input.routeId ?? null, @@ -237,14 +289,24 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise ].some((value) => value != null && String(value).trim().length > 0); const includeClientFields = requestedClientFields && await hasProxyLogClientColumns(); + const requestedStreamTimingFields = input.isStream != null || input.firstByteLatencyMs != null; + const includeStreamTimingFields = requestedStreamTimingFields + && await hasProxyLogStreamTimingColumns(); let allowBillingDetails = includeBillingDetails; let allowDownstreamApiKeyId = includeDownstreamApiKeyId; let allowClientFields = includeClientFields; + let allowStreamTimingFields = includeStreamTimingFields; while (true) { const values = { ...baseValues, + ...(allowStreamTimingFields + ? { + isStream: input.isStream ?? null, + firstByteLatencyMs: input.firstByteLatencyMs ?? null, + } + : {}), ...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}), ...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}), ...(allowClientFields @@ -276,6 +338,11 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise continue; } + if (allowStreamTimingFields && isMissingProxyLogStreamTimingColumnsError(error)) { + allowStreamTimingFields = false; + continue; + } + throw error; } } diff --git a/src/server/services/proxyRetryPolicy.ts b/src/server/services/proxyRetryPolicy.ts index 4702d0f..1e791ca 100644 --- a/src/server/services/proxyRetryPolicy.ts +++ b/src/server/services/proxyRetryPolicy.ts @@ -16,7 +16,7 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [ ]; export const RETRYABLE_TIMEOUT_PATTERNS: RegExp[] = [ - /(request timed out|connection timed out|read timeout|\btimed out\b)/i, + /(request timed out|connection timed out|read timeout|first byte timeout|\btimed out\b)/i, ]; const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [ diff --git a/src/server/services/siteApiEndpointService.ts b/src/server/services/siteApiEndpointService.ts index 9e577b2..d5913ef 100644 --- a/src/server/services/siteApiEndpointService.ts +++ b/src/server/services/siteApiEndpointService.ts @@ -49,14 +49,23 @@ export interface RecordedSiteApiEndpointFailure extends SiteApiEndpointFailureDi export class SiteApiEndpointRequestError extends Error { readonly status: number | null; readonly rawErrText: string | null; + readonly firstByteLatencyMs: number | null; - constructor(message: string, options?: { status?: number | null; rawErrText?: string | null; cause?: unknown }) { + constructor(message: string, options?: { + status?: number | null; + rawErrText?: string | null; + firstByteLatencyMs?: number | null; + cause?: unknown; + }) { super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); this.name = 'SiteApiEndpointRequestError'; this.status = typeof options?.status === 'number' ? options.status : null; this.rawErrText = typeof options?.rawErrText === 'string' && options.rawErrText.trim() ? options.rawErrText : null; + this.firstByteLatencyMs = typeof options?.firstByteLatencyMs === 'number' && Number.isFinite(options.firstByteLatencyMs) + ? options.firstByteLatencyMs + : null; } } diff --git a/src/web/api.ts b/src/web/api.ts index 9350984..6f01f09 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -387,6 +387,7 @@ export type RuntimeSettingsPayload = { notifyCooldownSec?: number; adminIpAllowlist?: string[] | string; routingFallbackUnitCost?: number; + proxyFirstByteTimeoutSec?: number; tokenRouterFailureCooldownMaxSec?: number; routingWeights?: RuntimeRoutingWeightsPayload; proxyErrorKeywords?: string[] | string; @@ -437,6 +438,8 @@ export type ProxyLogListItem = { modelActual: string; status: string; latencyMs: number; + isStream?: boolean | null; + firstByteLatencyMs?: number | null; totalTokens: number | null; retryCount: number; accountId?: number | null; diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index de70db0..1aea9c6 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -58,6 +58,8 @@ function buildListResponse(overrides?: Partial<{ modelActual: 'gpt-4o', status: 'success', latencyMs: 120, + firstByteLatencyMs: 35, + isStream: true, promptTokens: 10, completionTokens: 5, totalTokens: 15, @@ -146,6 +148,8 @@ describe('ProxyLogs server-driven page', () => { modelActual: 'gpt-4o', status: 'success', latencyMs: 120, + firstByteLatencyMs: 35, + isStream: true, promptTokens: 10, completionTokens: 5, totalTokens: 15, @@ -264,6 +268,8 @@ describe('ProxyLogs server-driven page', () => { expect(text).toContain('Codex'); expect(text).toContain('推测'); expect(text).toContain('下游 Key: 移动端灰度'); + expect(text).toContain('流式'); + expect(text).toContain('首字'); } finally { await act(async () => { root?.unmount(); @@ -801,6 +807,8 @@ describe('ProxyLogs server-driven page', () => { modelActual: 'gpt-4o', status: 'success', latencyMs: 120, + firstByteLatencyMs: 22, + isStream: false, promptTokens: 10, completionTokens: 5, totalTokens: 15, diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index b818c42..cb579fe 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -265,6 +265,28 @@ function latencyBgColor(ms: number) { return 'color-mix(in srgb, var(--color-success) 12%, transparent)'; } +function firstByteColor(ms: number) { + if (ms >= 3000) return 'var(--color-danger)'; + if (ms >= 1000) return 'var(--color-warning)'; + return 'var(--color-primary)'; +} + +function firstByteBgColor(ms: number) { + if (ms >= 3000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)'; + if (ms >= 1000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)'; + return 'color-mix(in srgb, var(--color-primary) 12%, transparent)'; +} + +function formatStreamModeLabel(isStream: boolean | null | undefined) { + if (isStream == null) return null; + return isStream ? '流式' : '非流'; +} + +function formatFirstByteLabel(ms: number | null | undefined) { + if (!Number.isFinite(ms) || typeof ms !== 'number' || ms < 0) return null; + return `首字 ${formatLatency(ms)}`; +} + function formatCompactNumber(value: number, digits = 6) { if (!Number.isFinite(value)) return '0'; const formatted = value.toFixed(digits).replace(/\.?0+$/, ''); @@ -1877,6 +1899,8 @@ export default function ProxyLogs() { const downstreamKeySummary = renderDownstreamKeySummary(detailLog); const isExpanded = expanded === log.id; const clientDisplay = resolveProxyLogClientDisplay(detailLog); + const streamModeLabel = formatStreamModeLabel(detailLog.isStream); + const firstByteLabel = formatFirstByteLabel(detailLog.firstByteLatencyMs); return ( ) : null} + {streamModeLabel ? ( + + {streamModeLabel} + + ) : null} + {firstByteLabel ? ( + + {firstByteLabel} + + ) : null}
@@ -1934,6 +1976,8 @@ export default function ProxyLogs() {
} /> + {streamModeLabel ? : null} + {firstByteLabel ? : null} 0 ? log.retryCount : 0} /> {detailState?.loading &&
加载详情中...
} @@ -1983,6 +2027,8 @@ export default function ProxyLogs() { const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null; const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : []; const downstreamKeySummary = renderDownstreamKeySummary(detailLog); + const streamModeLabel = formatStreamModeLabel(detailLog.isStream); + const firstByteLabel = formatFirstByteLabel(detailLog.firstByteLatencyMs); return ( @@ -2015,6 +2061,28 @@ export default function ProxyLogs() { {downstreamKeySummary}
) : null} + {(streamModeLabel || firstByteLabel) ? ( +
+ {streamModeLabel ? ( + + {streamModeLabel} + + ) : null} + {firstByteLabel ? ( + + {firstByteLabel} + + ) : null} +
+ ) : null}
@@ -2084,6 +2152,12 @@ export default function ProxyLogs() { <>{' -> '}实际模型: {detailLog.modelActual} )} ,状态: {detailLog.status === 'success' ? '成功' : '失败'} + {streamModeLabel && ( + <>,模式: {streamModeLabel} + )} + {firstByteLabel && ( + <>,首字: {formatLatency(detailLog.firstByteLatencyMs ?? 0)} + )} ,用时: {formatLatency(detailLog.latencyMs)} {detail && ( <> diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 6b0e458..c0bbb08 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -66,6 +66,7 @@ type RuntimeSettings = { proxySessionChannelConcurrencyLimit: number; proxySessionChannelQueueWaitMs: number; routingFallbackUnitCost: number; + proxyFirstByteTimeoutSec: number; routeFailureCooldownMaxValue: number; routeFailureCooldownMaxUnit: RouteCooldownUnit; routingWeights: RoutingWeights; @@ -244,6 +245,7 @@ export default function Settings() { proxySessionChannelConcurrencyLimit: 2, proxySessionChannelQueueWaitMs: 1500, routingFallbackUnitCost: 1, + proxyFirstByteTimeoutSec: 0, routeFailureCooldownMaxValue: 30, routeFailureCooldownMaxUnit: 'day', routingWeights: defaultWeights, @@ -496,6 +498,9 @@ export default function Settings() { routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0 ? Number(runtimeInfo.routingFallbackUnitCost) : 1, + proxyFirstByteTimeoutSec: Number(runtimeInfo.proxyFirstByteTimeoutSec) >= 0 + ? Math.trunc(Number(runtimeInfo.proxyFirstByteTimeoutSec)) + : 0, routeFailureCooldownMaxValue: routeCooldownInput.value, routeFailureCooldownMaxUnit: routeCooldownInput.unit, routingWeights: { @@ -942,6 +947,9 @@ export default function Settings() { await api.updateRuntimeSettings({ routingWeights: runtime.routingWeights, routingFallbackUnitCost: runtime.routingFallbackUnitCost, + proxyFirstByteTimeoutSec: Number.isFinite(runtime.proxyFirstByteTimeoutSec) + ? Math.max(0, Math.trunc(runtime.proxyFirstByteTimeoutSec)) + : 0, tokenRouterFailureCooldownMaxSec: toRouteCooldownSeconds( runtime.routeFailureCooldownMaxValue, runtime.routeFailureCooldownMaxUnit, @@ -1739,6 +1747,32 @@ export default function Settings() { +
+
+ 首字超时(无首包 / 首 token) +
+ { + const nextValue = Number(e.target.value); + setRuntime((prev) => ({ + ...prev, + proxyFirstByteTimeoutSec: Number.isFinite(nextValue) && nextValue >= 0 + ? Math.trunc(nextValue) + : prev.proxyFirstByteTimeoutSec, + })); + }} + style={inputStyle} + /> +
+ `0` 表示关闭。只有在指定时间内完全没有任何首包 / 首 token 返回时才切换,已经开始输出的请求不会被这项超时打断。 +
+
+
diff --git a/src/web/pages/settings.route-cooldown.test.tsx b/src/web/pages/settings.route-cooldown.test.tsx index 2886f7c..1b1e3c2 100644 --- a/src/web/pages/settings.route-cooldown.test.tsx +++ b/src/web/pages/settings.route-cooldown.test.tsx @@ -57,6 +57,7 @@ describe('Settings route cooldown cap', () => { logCleanupProgramLogsEnabled: true, logCleanupRetentionDays: 14, routingFallbackUnitCost: 1, + proxyFirstByteTimeoutSec: 0, routingWeights: {}, tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60, adminIpAllowlist: [], @@ -127,6 +128,7 @@ describe('Settings route cooldown cap', () => { usageWeight: 0.3, }, routingFallbackUnitCost: 1, + proxyFirstByteTimeoutSec: 0, tokenRouterFailureCooldownMaxSec: 10, disableCrossProtocolFallback: false, }); @@ -146,6 +148,7 @@ describe('Settings route cooldown cap', () => { logCleanupProgramLogsEnabled: true, logCleanupRetentionDays: 14, routingFallbackUnitCost: 1, + proxyFirstByteTimeoutSec: 0, routingWeights: {}, tokenRouterFailureCooldownMaxSec: 10, adminIpAllowlist: [], @@ -181,4 +184,57 @@ describe('Settings route cooldown cap', () => { root?.unmount(); } }); + + it('saves the first-byte timeout seconds alongside other routing runtime settings', async () => { + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const firstByteInput = root.root.find((node) => ( + node.type === 'input' + && node.props.type === 'number' + && node.props['aria-label'] === '首字超时秒数' + )); + + await act(async () => { + firstByteInput.props.onChange({ target: { value: '7' } }); + }); + + const saveButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '保存路由策略' + )); + + await act(async () => { + saveButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({ + routingWeights: { + baseWeightFactor: 0.5, + valueScoreFactor: 0.5, + costWeight: 0.4, + balanceWeight: 0.3, + usageWeight: 0.3, + }, + routingFallbackUnitCost: 1, + proxyFirstByteTimeoutSec: 7, + tokenRouterFailureCooldownMaxSec: 30 * 24 * 60 * 60, + disableCrossProtocolFallback: false, + }); + } finally { + root?.unmount(); + } + }); });