diff --git a/docs/streaming-discovery.md b/docs/streaming-discovery.md new file mode 100644 index 000000000..39d261241 --- /dev/null +++ b/docs/streaming-discovery.md @@ -0,0 +1,69 @@ +# Bounded Streaming Discovery + +Bounded Discovery is an optional cold-start routing mode for streaming +requests. It exists to reduce duplicate upstream spend while preserving a +working request when the first provider is slow. + +## Defaults + +| Setting | Default | Meaning | +| --- | ---: | --- | +| `discoveryEnabled` | `false` | Keep the existing Hedge path until explicitly enabled. | +| `discoveryConcurrency` | `2` | Number of normal providers in the first batch. | +| `maxDiscoveryRounds` | `2` | Maximum Discovery rounds. | +| `discoverySlaMs` | `10000` | First-byte budget for a Discovery round. | +| `stickySlaMs` | `20000` | First-byte budget for an existing Sticky provider. | +| `racingTotalTimeoutMs` | `60000` | Total pre-winner deadline; it is cleared after a winner is committed. | +| `stickyTimeoutCooldownMs` | `300000` | Session/provider cooldown after a Sticky timeout. | + +The total deadline must be at least `stickySlaMs + maxDiscoveryRounds * +discoverySlaMs`. The UI and API reject configurations that do not satisfy +this relationship. + +## Request lifecycle + +- A healthy Sticky provider is probed alone. If it times out, it becomes the + single fallback for this request and receives a cooldown; a later request + may select it again after the cooldown. +- A cold start launches the configured initial normal candidates. The highest + priority ready candidate wins; a lower-priority candidate remains held while + a higher-priority candidate is still inside its SLA window. +- At a round boundary, at most one pending normal attempt is promoted to the + fallback. The next round uses the remaining slots for new normal candidates, + so `discoveryConcurrency=2` means `one fallback + one new candidate`. +- A fallback that has produced a valid prefix is held until the current normal + window closes, all normal candidates fail, or no candidates remain. A normal + winner always has precedence during the window. +- Discovery losers are cancelled and their readers/agents/provider-session + references are released. They do not enter legacy `bill_hedge_losers` + draining. +- Sticky binding is written only after a natural, successful stream completion + with the protocol completion marker and a generation-aware CAS. Fake-200, + incomplete, and client-aborted streams do not create or renew Sticky. + +Discovery is eligible only for supported streaming protocol families and when +the versioned Redis binding capability is available. If Redis capability is +unknown/unavailable, the existing provider selection and Hedge behavior remain +active. + +The versioned binding scripts require the canonical binding, legacy provider, +legacy owner, lease, and cooldown keys to be evaluated atomically. On Redis +Cluster layouts where those keys do not share a slot and Redis returns +`CROSSSLOT` (or when Lua capability probing fails), the capability state is +`unavailable`; the service records that state and uses the tenant-checked +legacy wrapper. Discovery stays disabled until a later connection-lifecycle +probe succeeds. + +## Rollout + +1. Apply the system-settings migration. +2. Confirm the Redis versioned-binding capability probe is `available`. +3. Leave `discoveryEnabled=false` while validating the existing Hedge and + versioned binding checks. +4. Enable Discovery for a controlled group, observe provider-chain outcomes, + first-token latency, fallback promotions, cancellations, CAS conflicts, and + final 503s. +5. Disable the setting to return immediately to the legacy Hedge path. + +This feature does not change the final client failure contract: an exhausted +request continues to return the existing `503` mapping. diff --git a/drizzle/0110_daffy_rawhide_kid.sql b/drizzle/0110_daffy_rawhide_kid.sql new file mode 100644 index 000000000..ff57e9a44 --- /dev/null +++ b/drizzle/0110_daffy_rawhide_kid.sql @@ -0,0 +1,7 @@ +ALTER TABLE "system_settings" ADD COLUMN "discovery_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "discovery_concurrency" integer DEFAULT 2 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "max_discovery_rounds" integer DEFAULT 2 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "discovery_sla_ms" integer DEFAULT 10000 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "sticky_sla_ms" integer DEFAULT 20000 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "racing_total_timeout_ms" integer DEFAULT 60000 NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "sticky_timeout_cooldown_ms" integer DEFAULT 300000 NOT NULL; diff --git a/drizzle/meta/0110_snapshot.json b/drizzle/meta/0110_snapshot.json new file mode 100644 index 000000000..66d670d57 --- /dev/null +++ b/drizzle/meta/0110_snapshot.json @@ -0,0 +1,4691 @@ +{ + "id": "b6b7996d-5b70-4a31-a1c7-3a318b3398a0", + "prevId": "c054c34a-98a4-4ae1-b0e5-0b663380f123", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_image_generation_preference": { + "name": "codex_image_generation_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovery_concurrency": { + "name": "discovery_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "max_discovery_rounds": { + "name": "max_discovery_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "discovery_sla_ms": { + "name": "discovery_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "sticky_sla_ms": { + "name": "sticky_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20000 + }, + "racing_total_timeout_ms": { + "name": "racing_total_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "sticky_timeout_cooldown_ms": { + "name": "sticky_timeout_cooldown_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300000 + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_gemini_function_id_rectifier": { + "name": "enable_gemini_function_id_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 609e69822..cdc138e1e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -766,11 +766,18 @@ "breakpoints": true }, { - "idx": 109, + "idx": 109, + "version": "7", + "when": 1784067387303, + "tag": "0109_nice_shriek", + "breakpoints": true + }, + { + "idx": 110, "version": "7", - "when": 1784067387303, - "tag": "0109_nice_shriek", + "when": 1784571513591, + "tag": "0110_daffy_rawhide_kid", "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 0440d2c83..149408b5e 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -118,6 +118,17 @@ "billHedgeLosers": "Bill Provider-Racing Losers by Token Usage", "billHedgeLosersDesc": "When provider racing (streaming hedge) is on, losing providers are kept connected in the background, drained for their token usage, and billed - their cost is added into this request's total. Default on.", "billHedgeLosersTooltip": "Upstreams usually bill a request even after we cancel it. Keeping racing losers alive lets us reclaim their token counts so CCH's cost matches what every upstream actually charged. Each loser's cost is accumulated asynchronously into the request total.", + "discoveryEnabled": "Enable bounded provider Discovery", + "discoveryEnabledDesc": "When enabled, cold-start streaming requests probe multiple providers within a bounded window and keep at most one fallback. It is disabled by default.", + "discoveryConcurrency": "Discovery initial concurrency", + "maxDiscoveryRounds": "Discovery maximum rounds", + "discoverySlaMs": "Discovery SLA (milliseconds)", + "stickySlaMs": "Sticky SLA (milliseconds)", + "racingTotalTimeoutMs": "Discovery total timeout (milliseconds)", + "stickyTimeoutCooldownMs": "Sticky timeout cooldown (milliseconds)", + "discoveryWindowDesc": "The total timeout must be at least Sticky SLA + maximum rounds × Discovery SLA. Discovery losers are cancelled and are not drained or billed by the legacy Hedge path.", + "discoveryWindowInvalid": "Discovery total timeout is shorter than the configured Sticky and Discovery windows.", + "discoverySettingsInvalid": "One or more Discovery values are outside the allowed range.", "verboseProviderError": "Verbose Provider Error", "verboseProviderErrorDesc": "When enabled, CCH may return detailed diagnostic information for some upstream failure types in `error.details` (for example provider availability diagnostics or sanitized upstream snippets).", "verboseProviderErrorTooltip": "May expose provider names, internal routing clues, upstream failure reasons, and other diagnostic details. Enable only if clients are allowed to see low-level troubleshooting context.", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 0fc81c84a..e49d28c1b 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -120,6 +120,17 @@ "billHedgeLosers": "プロバイダー競争(hedge)の敗者を Token 使用量で課金", "billHedgeLosersDesc": "プロバイダー競争(ストリーミング hedge)が有効な場合、競争に敗れたプロバイダーを即座に切断せず、バックグラウンドで接続を維持して token 使用量を取得し課金します。その費用はこのリクエストの合計に加算されます。既定はオン。", "billHedgeLosersTooltip": "上流はこちらが能動的にキャンセルしたリクエストも通常は課金します。競争の敗者を生かしておくことで token 数を回収し、CCH の課金を各上流の実際の課金と一致させます。各敗者の費用は非同期にリクエストの費用へ加算されます。", + "discoveryEnabled": "制限付き Provider Discovery を有効化", + "discoveryEnabledDesc": "有効にすると、コールドスタートのストリーミングリクエストで複数 Provider を制限時間内に探索し、フォールバックを最大 1 つ保持します。既定はオフです。", + "discoveryConcurrency": "Discovery 初期並列数", + "maxDiscoveryRounds": "Discovery 最大ラウンド数", + "discoverySlaMs": "Discovery SLA(ミリ秒)", + "stickySlaMs": "Sticky SLA(ミリ秒)", + "racingTotalTimeoutMs": "Discovery 合計タイムアウト(ミリ秒)", + "stickyTimeoutCooldownMs": "Sticky タイムアウト後のクールダウン(ミリ秒)", + "discoveryWindowDesc": "合計タイムアウトは Sticky SLA + 最大ラウンド数 × Discovery SLA 以上にしてください。", + "discoveryWindowInvalid": "Discovery 合計タイムアウトが設定された Sticky/Discovery ウィンドウより短くなっています。", + "discoverySettingsInvalid": "1 つ以上の Discovery 設定値が許容範囲外です。", "verboseProviderError": "詳細なプロバイダーエラー", "verboseProviderErrorDesc": "有効にすると、一部の上流障害タイプで `error.details` により詳細な診断情報(プロバイダー可用性の診断やサニタイズ済み上流断片など)を含める場合があります。", "verboseProviderErrorTooltip": "この設定を有効にすると、プロバイダー名、内部ルーティングの手掛かり、上流障害の理由などの診断情報が露出する可能性があります。クライアントに低レベルのトラブルシュート文脈を見せてもよい場合にのみ有効化してください。", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 05e3dc217..9624cf448 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -120,6 +120,17 @@ "billHedgeLosers": "Тарифицировать проигравших в гонке провайдеров по токенам", "billHedgeLosersDesc": "Когда включена гонка провайдеров (streaming hedge), проигравшие провайдеры не отключаются сразу, а остаются на связи в фоне, дочитываются для получения использования токенов и тарифицируются - их стоимость добавляется в общую стоимость этого запроса. По умолчанию включено.", "billHedgeLosersTooltip": "Апстримы обычно тарифицируют запрос, даже если мы его отменили. Сохраняя проигравших в гонке, мы получаем их счётчики токенов, чтобы расходы CCH совпадали с тем, что фактически списал каждый апстрим. Стоимость каждого проигравшего асинхронно добавляется к стоимости запроса.", + "discoveryEnabled": "Включить ограниченное обнаружение провайдеров", + "discoveryEnabledDesc": "При включении потоковые запросы холодного старта проверяют несколько провайдеров в ограниченном окне и сохраняют не более одного резервного. По умолчанию выключено.", + "discoveryConcurrency": "Начальная параллельность Discovery", + "maxDiscoveryRounds": "Максимальное число раундов Discovery", + "discoverySlaMs": "SLA Discovery (миллисекунды)", + "stickySlaMs": "SLA Sticky (миллисекунды)", + "racingTotalTimeoutMs": "Общий тайм-аут Discovery (миллисекунды)", + "stickyTimeoutCooldownMs": "Пауза после тайм-аута Sticky (миллисекунды)", + "discoveryWindowDesc": "Общий тайм-аут должен быть не меньше SLA Sticky + максимальное число раундов × SLA Discovery.", + "discoveryWindowInvalid": "Общий тайм-аут Discovery меньше настроенного окна Sticky и Discovery.", + "discoverySettingsInvalid": "Одно или несколько значений Discovery находятся вне допустимого диапазона.", "verboseProviderError": "Подробные ошибки провайдеров", "verboseProviderErrorDesc": "При включении CCH может добавлять более подробную диагностику некоторых типов сбоев апстрима в `error.details` (например, диагностику доступности провайдеров или очищенные фрагменты ответа апстрима).", "verboseProviderErrorTooltip": "Может раскрывать названия провайдеров, внутренние подсказки маршрутизации, причины сбоев апстрима и другие диагностические детали. Включайте только если клиентам допустимо видеть низкоуровневый контекст отладки.", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 19ea20509..c4a94ea93 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -47,6 +47,17 @@ "billHedgeLosers": "对供应商竞速输家计费", "billHedgeLosersDesc": "开启供应商竞速后,竞速落败的供应商不再被直接掐断,而是在后台保持连接、拿回其 token 用量并计费,其费用会累加进本条请求的总花费。默认开启。", "billHedgeLosersTooltip": "上游通常即使请求被我们主动取消也照样计费。保活竞速输家可以拿回它们的 token 计数,使 CCH 的扣费与每个上游实际扣费保持一致。每个输家的费用都会异步累加到该请求的花费中。", + "discoveryEnabled": "启用有界供应商 Discovery", + "discoveryEnabledDesc": "启用后,冷启动流式请求会在限定窗口内探测多个供应商,并且最多保留一个保底请求。默认关闭。", + "discoveryConcurrency": "Discovery 首轮并发数", + "maxDiscoveryRounds": "Discovery 最大轮数", + "discoverySlaMs": "Discovery SLA(毫秒)", + "stickySlaMs": "Sticky SLA(毫秒)", + "racingTotalTimeoutMs": "Discovery 总超时(毫秒)", + "stickyTimeoutCooldownMs": "Sticky 超时冷却(毫秒)", + "discoveryWindowDesc": "总超时必须不小于 Sticky SLA + 最大轮数 × Discovery SLA。Discovery 输家会取消,不走旧 Hedge 的 drain 或输家计费。", + "discoveryWindowInvalid": "Discovery 总超时短于已配置的 Sticky 与 Discovery 窗口。", + "discoverySettingsInvalid": "一个或多个 Discovery 配置值超出允许范围。", "verboseProviderError": "详细供应商错误信息", "verboseProviderErrorDesc": "开启后,CCH 会在某些上游失败类型下于 `error.details` 返回更详细的诊断信息(例如供应商可用性诊断或脱敏后的上游片段)。", "verboseProviderErrorTooltip": "该选项可能暴露供应商名称、内部路由线索、上游失败原因等诊断信息。仅建议在客户端可以查看底层排障上下文时开启。", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 590ff7a77..613494c92 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -120,6 +120,17 @@ "billHedgeLosers": "對供應商競速輸家計費", "billHedgeLosersDesc": "開啟供應商競速後,競速落敗的供應商不再被直接掐斷,而是在後台保持連線、取回其 token 用量並計費,其費用會累加進本條請求的總花費。預設開啟。", "billHedgeLosersTooltip": "上游通常即使請求被我們主動取消也照常計費。保活競速輸家可以取回它們的 token 計數,使 CCH 的扣費與每個上游實際扣費保持一致。每個輸家的費用都會非同步累加到該請求的花費中。", + "discoveryEnabled": "啟用有界供應商 Discovery", + "discoveryEnabledDesc": "啟用後,冷啟動串流請求會在限定視窗內探測多個供應商,並且最多保留一個保底請求。預設關閉。", + "discoveryConcurrency": "Discovery 首輪並發數", + "maxDiscoveryRounds": "Discovery 最大輪數", + "discoverySlaMs": "Discovery 每輪 SLA(毫秒)", + "stickySlaMs": "Sticky 等待 SLA(毫秒)", + "racingTotalTimeoutMs": "Discovery 總逾時(毫秒)", + "stickyTimeoutCooldownMs": "Sticky 逾時冷卻(毫秒)", + "discoveryWindowDesc": "總逾時必須不小於 Sticky SLA + 最大輪數 × Discovery SLA。Discovery 輸家會取消,不走舊 Hedge 的 drain 或輸家計費。", + "discoveryWindowInvalid": "Discovery 總逾時短於已設定的 Sticky 與 Discovery 視窗。", + "discoverySettingsInvalid": "一個或多個 Discovery 設定值超出允許範圍。", "verboseProviderError": "詳細供應商錯誤資訊", "verboseProviderErrorDesc": "開啟後,CCH 會在某些上游失敗類型下於 `error.details` 返回較詳細的診斷資訊(例如供應商可用性診斷或脫敏後的上游片段)。", "verboseProviderErrorTooltip": "此選項可能暴露供應商名稱、內部路由線索、上游失敗原因等診斷資訊。僅建議在客戶端可以查看底層排障上下文時開啟。", diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 5c42d12d0..4ffbdf87c 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -1,10 +1,12 @@ "use server"; import { revalidatePath } from "next/cache"; +import { ZodError } from "zod"; import { locales } from "@/i18n/config"; import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import { invalidateSystemSettingsCache } from "@/lib/config"; +import { DEFAULT_SETTINGS } from "@/lib/config/system-settings-cache"; import { logger } from "@/lib/logger"; import { publishCurrentPublicStatusConfigProjection } from "@/lib/public-status/config-publisher"; import { schedulePublicStatusRebuild } from "@/lib/public-status/rebuild-hints"; @@ -14,6 +16,10 @@ import { invalidateAllStatisticsCaches, } from "@/lib/redis"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { + DISCOVERY_WINDOW_INVALID_ERROR_CODE, + getDiscoveryValidationErrorCode, +} from "@/lib/validation/discovery-settings"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; import type { IpExtractionConfig } from "@/types/ip-extraction"; @@ -25,6 +31,11 @@ import type { } from "@/types/system-config"; import type { ActionResult } from "./types"; +function discoveryValidationErrorCode(error: unknown): string | null { + if (!(error instanceof ZodError)) return null; + return getDiscoveryValidationErrorCode(error.issues) ?? null; +} + export async function fetchSystemSettings(): Promise> { try { const session = await getSession(); @@ -64,6 +75,13 @@ export async function saveSystemSettings(formData: { codexPriorityBillingSource?: CodexPriorityBillingSource; billNonSuccessfulRequests?: boolean; billHedgeLosers?: boolean; + discoveryEnabled?: boolean; + discoveryConcurrency?: number; + maxDiscoveryRounds?: number; + discoverySlaMs?: number; + stickySlaMs?: number; + racingTotalTimeoutMs?: number; + stickyTimeoutCooldownMs?: number; timezone?: string | null; enableAutoCleanup?: boolean; cleanupRetentionDays?: number; @@ -110,6 +128,30 @@ export async function saveSystemSettings(formData: { before = await getSystemSettings(); const validated = UpdateSystemSettingsSchema.parse(formData); + const effectiveDiscoveryWindow = { + discoverySlaMs: + validated.discoverySlaMs ?? before?.discoverySlaMs ?? DEFAULT_SETTINGS.discoverySlaMs, + stickySlaMs: validated.stickySlaMs ?? before?.stickySlaMs ?? DEFAULT_SETTINGS.stickySlaMs, + maxDiscoveryRounds: + validated.maxDiscoveryRounds ?? + before?.maxDiscoveryRounds ?? + DEFAULT_SETTINGS.maxDiscoveryRounds, + racingTotalTimeoutMs: + validated.racingTotalTimeoutMs ?? + before?.racingTotalTimeoutMs ?? + DEFAULT_SETTINGS.racingTotalTimeoutMs, + }; + if ( + effectiveDiscoveryWindow.racingTotalTimeoutMs < + effectiveDiscoveryWindow.stickySlaMs + + effectiveDiscoveryWindow.maxDiscoveryRounds * effectiveDiscoveryWindow.discoverySlaMs + ) { + return { + ok: false, + error: "Discovery window validation failed.", + errorCode: DISCOVERY_WINDOW_INVALID_ERROR_CODE, + }; + } const updated = await updateSystemSettings({ siteTitle: validated.siteTitle?.trim(), allowGlobalUsageView: validated.allowGlobalUsageView, @@ -118,6 +160,13 @@ export async function saveSystemSettings(formData: { codexPriorityBillingSource: validated.codexPriorityBillingSource, billNonSuccessfulRequests: validated.billNonSuccessfulRequests, billHedgeLosers: validated.billHedgeLosers, + discoveryEnabled: validated.discoveryEnabled, + discoveryConcurrency: validated.discoveryConcurrency, + maxDiscoveryRounds: validated.maxDiscoveryRounds, + discoverySlaMs: validated.discoverySlaMs, + stickySlaMs: validated.stickySlaMs, + racingTotalTimeoutMs: validated.racingTotalTimeoutMs, + stickyTimeoutCooldownMs: validated.stickyTimeoutCooldownMs, timezone: validated.timezone, enableAutoCleanup: validated.enableAutoCleanup, cleanupRetentionDays: validated.cleanupRetentionDays, @@ -239,7 +288,12 @@ export async function saveSystemSettings(formData: { return { ok: true, data: { ...updated, publicStatusProjectionWarningCode } }; } catch (error) { logger.error("更新系统设置失败:", error); - const message = error instanceof Error ? error.message : "更新系统设置失败"; + const validationErrorCode = discoveryValidationErrorCode(error); + const message = validationErrorCode + ? "Discovery settings validation failed." + : error instanceof Error + ? error.message + : "更新系统设置失败"; emitActionAudit({ category: "system_settings", action: "system_settings.update", @@ -249,6 +303,10 @@ export async function saveSystemSettings(formData: { success: false, errorMessage: "UPDATE_FAILED", }); - return { ok: false, error: message }; + return { + ok: false, + error: message, + ...(validationErrorCode ? { errorCode: validationErrorCode } : {}), + }; } } diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index dabd8e344..ba8a33b32 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -47,6 +47,7 @@ import { shouldWarnQuotaLeaseCapZero, shouldWarnQuotaLeasePercentZero, } from "@/lib/utils/validation/quota-lease-warnings"; +import { DISCOVERY_FIELD_LIMITS } from "@/lib/validation/discovery-settings"; import { DEFAULT_IP_EXTRACTION_CONFIG, type IpExtractionConfig } from "@/types/ip-extraction"; import type { BillingModelSource, @@ -65,6 +66,13 @@ interface SystemSettingsFormProps { | "codexPriorityBillingSource" | "billNonSuccessfulRequests" | "billHedgeLosers" + | "discoveryEnabled" + | "discoveryConcurrency" + | "maxDiscoveryRounds" + | "discoverySlaMs" + | "stickySlaMs" + | "racingTotalTimeoutMs" + | "stickyTimeoutCooldownMs" | "timezone" | "verboseProviderError" | "passThroughUpstreamErrorMessage" @@ -107,6 +115,7 @@ function formatIpExtractionConfig(config: IpExtractionConfig): string { } const DEFAULT_IP_EXTRACTION_CONFIG_TEXT = formatIpExtractionConfig(DEFAULT_IP_EXTRACTION_CONFIG); +type DiscoveryNumberValue = number | ""; export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) { const router = useRouter(); @@ -130,6 +139,23 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) initialSettings.billNonSuccessfulRequests ); const [billHedgeLosers, setBillHedgeLosers] = useState(initialSettings.billHedgeLosers); + const [discoveryEnabled, setDiscoveryEnabled] = useState(initialSettings.discoveryEnabled); + const [discoveryConcurrency, setDiscoveryConcurrency] = useState( + initialSettings.discoveryConcurrency + ); + const [maxDiscoveryRounds, setMaxDiscoveryRounds] = useState( + initialSettings.maxDiscoveryRounds + ); + const [discoverySlaMs, setDiscoverySlaMs] = useState( + initialSettings.discoverySlaMs + ); + const [stickySlaMs, setStickySlaMs] = useState(initialSettings.stickySlaMs); + const [racingTotalTimeoutMs, setRacingTotalTimeoutMs] = useState( + initialSettings.racingTotalTimeoutMs + ); + const [stickyTimeoutCooldownMs, setStickyTimeoutCooldownMs] = useState( + initialSettings.stickyTimeoutCooldownMs + ); const [timezone, setTimezone] = useState(initialSettings.timezone); const [verboseProviderError, setVerboseProviderError] = useState( initialSettings.verboseProviderError @@ -230,6 +256,32 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) return; } + const discoveryConfig = { + discoveryConcurrency: Number(discoveryConcurrency), + maxDiscoveryRounds: Number(maxDiscoveryRounds), + discoverySlaMs: Number(discoverySlaMs), + stickySlaMs: Number(stickySlaMs), + racingTotalTimeoutMs: Number(racingTotalTimeoutMs), + stickyTimeoutCooldownMs: Number(stickyTimeoutCooldownMs), + }; + const discoverySettingsInvalid = Object.entries(discoveryConfig).some(([field, value]) => { + const [min, max] = DISCOVERY_FIELD_LIMITS[field as keyof typeof DISCOVERY_FIELD_LIMITS]; + return !Number.isSafeInteger(value) || value < min || value > max; + }); + if (discoveryEnabled && discoverySettingsInvalid) { + toast.error(t("discoverySettingsInvalid")); + return; + } + if ( + discoveryEnabled && + discoveryConfig.racingTotalTimeoutMs < + discoveryConfig.stickySlaMs + + discoveryConfig.maxDiscoveryRounds * discoveryConfig.discoverySlaMs + ) { + toast.error(t("discoveryWindowInvalid")); + return; + } + const quotaDbRefreshIntervalSecondsToSave = clampQuotaDbRefreshIntervalSeconds( quotaDbRefreshIntervalSecondsStr ); @@ -311,6 +363,8 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) codexPriorityBillingSource, billNonSuccessfulRequests, billHedgeLosers, + discoveryEnabled, + ...(discoveryEnabled ? discoveryConfig : {}), timezone, verboseProviderError, passThroughUpstreamErrorMessage, @@ -341,7 +395,13 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) }); if (!result.ok) { - toast.error(result.error || t("saveFailed")); + const errorMessage = + result.errorCode === "DISCOVERY_WINDOW_INVALID" + ? t("discoveryWindowInvalid") + : result.errorCode === "DISCOVERY_SETTINGS_INVALID" + ? t("discoverySettingsInvalid") + : result.error || t("saveFailed"); + toast.error(errorMessage); return; } @@ -353,6 +413,13 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setCodexPriorityBillingSource(result.data.codexPriorityBillingSource); setBillNonSuccessfulRequests(result.data.billNonSuccessfulRequests); setBillHedgeLosers(result.data.billHedgeLosers); + setDiscoveryEnabled(result.data.discoveryEnabled); + setDiscoveryConcurrency(result.data.discoveryConcurrency); + setMaxDiscoveryRounds(result.data.maxDiscoveryRounds); + setDiscoverySlaMs(result.data.discoverySlaMs); + setStickySlaMs(result.data.stickySlaMs); + setRacingTotalTimeoutMs(result.data.racingTotalTimeoutMs); + setStickyTimeoutCooldownMs(result.data.stickyTimeoutCooldownMs); setTimezone(result.data.timezone); setVerboseProviderError(result.data.verboseProviderError); setPassThroughUpstreamErrorMessage(result.data.passThroughUpstreamErrorMessage); @@ -636,6 +703,94 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) /> + {/* Bounded Streaming Discovery */} +
+
+
+
+ +
+
+

{t("discoveryEnabled")}

+

{t("discoveryEnabledDesc")}

+
+
+ +
+ {discoveryEnabled ? ( + <> +
+ {( + [ + [ + "discoveryConcurrency", + discoveryConcurrency, + setDiscoveryConcurrency, + ...DISCOVERY_FIELD_LIMITS.discoveryConcurrency, + ], + [ + "maxDiscoveryRounds", + maxDiscoveryRounds, + setMaxDiscoveryRounds, + ...DISCOVERY_FIELD_LIMITS.maxDiscoveryRounds, + ], + [ + "discoverySlaMs", + discoverySlaMs, + setDiscoverySlaMs, + ...DISCOVERY_FIELD_LIMITS.discoverySlaMs, + ], + [ + "stickySlaMs", + stickySlaMs, + setStickySlaMs, + ...DISCOVERY_FIELD_LIMITS.stickySlaMs, + ], + [ + "racingTotalTimeoutMs", + racingTotalTimeoutMs, + setRacingTotalTimeoutMs, + ...DISCOVERY_FIELD_LIMITS.racingTotalTimeoutMs, + ], + [ + "stickyTimeoutCooldownMs", + stickyTimeoutCooldownMs, + setStickyTimeoutCooldownMs, + ...DISCOVERY_FIELD_LIMITS.stickyTimeoutCooldownMs, + ], + ] as const + ).map(([key, value, setter, min, max]) => ( +
+ + + setter(event.target.value === "" ? "" : Number(event.target.value)) + } + disabled={isPending} + className={inputClassName} + /> +
+ ))} +
+

{t("discoveryWindowDesc")}

+ + ) : null} +
+ {/* Verbose Provider Error */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index b5eade7e6..d80019bd0 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -52,6 +52,13 @@ async function SettingsConfigContent({ locale }: { locale: string }) { codexPriorityBillingSource: settings.codexPriorityBillingSource, billNonSuccessfulRequests: settings.billNonSuccessfulRequests, billHedgeLosers: settings.billHedgeLosers, + discoveryEnabled: settings.discoveryEnabled, + discoveryConcurrency: settings.discoveryConcurrency, + maxDiscoveryRounds: settings.maxDiscoveryRounds, + discoverySlaMs: settings.discoverySlaMs, + stickySlaMs: settings.stickySlaMs, + racingTotalTimeoutMs: settings.racingTotalTimeoutMs, + stickyTimeoutCooldownMs: settings.stickyTimeoutCooldownMs, timezone: settings.timezone, verboseProviderError: settings.verboseProviderError, passThroughUpstreamErrorMessage: settings.passThroughUpstreamErrorMessage, diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index e9abff730..59733bf5c 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -7,6 +7,7 @@ import { invalidateAllOverviewCaches, invalidateAllStatisticsCaches, } from "@/lib/redis"; +import { DISCOVERY_WINDOW_INVALID_ERROR_CODE } from "@/lib/validation/discovery-settings"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; @@ -56,9 +57,20 @@ export async function POST(req: Request) { try { const body = await req.json(); + const current = await getSystemSettings(); // 验证请求数据 const validated = UpdateSystemSettingsSchema.parse(body); + const discoverySlaMs = validated.discoverySlaMs ?? current.discoverySlaMs; + const stickySlaMs = validated.stickySlaMs ?? current.stickySlaMs; + const maxDiscoveryRounds = validated.maxDiscoveryRounds ?? current.maxDiscoveryRounds; + const racingTotalTimeoutMs = validated.racingTotalTimeoutMs ?? current.racingTotalTimeoutMs; + if (racingTotalTimeoutMs < stickySlaMs + maxDiscoveryRounds * discoverySlaMs) { + return Response.json( + { error: "discoveryWindowInvalid", errorCode: DISCOVERY_WINDOW_INVALID_ERROR_CODE }, + { status: 400 } + ); + } // 更新系统设置 const updated = await updateSystemSettings({ @@ -67,6 +79,13 @@ export async function POST(req: Request) { currencyDisplay: validated.currencyDisplay, billingModelSource: validated.billingModelSource, codexPriorityBillingSource: validated.codexPriorityBillingSource, + discoveryEnabled: validated.discoveryEnabled, + discoveryConcurrency: validated.discoveryConcurrency, + maxDiscoveryRounds: validated.maxDiscoveryRounds, + discoverySlaMs: validated.discoverySlaMs, + stickySlaMs: validated.stickySlaMs, + racingTotalTimeoutMs: validated.racingTotalTimeoutMs, + stickyTimeoutCooldownMs: validated.stickyTimeoutCooldownMs, timezone: validated.timezone, enableAutoCleanup: validated.enableAutoCleanup, cleanupRetentionDays: validated.cleanupRetentionDays, diff --git a/src/app/api/v1/resources/system/handlers.ts b/src/app/api/v1/resources/system/handlers.ts index 942c103c5..21df4a018 100644 --- a/src/app/api/v1/resources/system/handlers.ts +++ b/src/app/api/v1/resources/system/handlers.ts @@ -8,6 +8,7 @@ import { import { parseHonoJsonBody } from "@/lib/api/v1/_shared/request-body"; import { jsonResponse } from "@/lib/api/v1/_shared/response-helpers"; import { SystemSettingsUpdateSchema } from "@/lib/api/v1/schemas/system-config"; +import { getDiscoveryValidationErrorCode } from "@/lib/validation/discovery-settings"; export async function getSystemSettings(c: Context): Promise { const actions = await import("@/actions/system-config"); @@ -27,7 +28,9 @@ export async function getSystemDisplaySettings(_c: Context): Promise { } export async function updateSystemSettings(c: Context): Promise { - const body = await parseHonoJsonBody(c, SystemSettingsUpdateSchema); + const body = await parseHonoJsonBody(c, SystemSettingsUpdateSchema, { + validationErrorCode: (error) => getDiscoveryValidationErrorCode(error.issues), + }); if (!body.ok) return body.response; const actions = await import("@/actions/system-config"); const result = await callAction( diff --git a/src/app/api/v1/resources/system/router.ts b/src/app/api/v1/resources/system/router.ts index 9abf57934..3e693e98f 100644 --- a/src/app/api/v1/resources/system/router.ts +++ b/src/app/api/v1/resources/system/router.ts @@ -9,6 +9,7 @@ import { SystemSettingsUpdateSchema, SystemTimezoneResponseSchema, } from "@/lib/api/v1/schemas/system-config"; +import { getDiscoveryValidationErrorCode } from "@/lib/validation/discovery-settings"; import { getSystemDisplaySettings, getSystemSettings, @@ -18,7 +19,13 @@ import { export const systemRouter = new OpenAPIHono({ defaultHook: (result, c) => { - if (!result.success) return fromZodError(result.error, new URL(c.req.url).pathname); + if (!result.success) { + return fromZodError( + result.error, + new URL(c.req.url).pathname, + getDiscoveryValidationErrorCode(result.error.issues) + ); + } }, }); diff --git a/src/app/v1/_lib/proxy/discovery-coordinator.ts b/src/app/v1/_lib/proxy/discovery-coordinator.ts index b7879a8dc..922bbf496 100644 --- a/src/app/v1/_lib/proxy/discovery-coordinator.ts +++ b/src/app/v1/_lib/proxy/discovery-coordinator.ts @@ -20,6 +20,8 @@ export type DiscoveryAttempt = { providerId: number; priority: number; kind: DiscoveryAttemptKind; + /** Selector/endpoint setup occupies a slot but cannot become a winner or fallback. */ + setupOnly?: boolean; ready: boolean; pending: boolean; round: number; @@ -37,7 +39,7 @@ export type DiscoveryAction = promoteAttemptId?: string; } | { type: "none" } - | { type: "terminal_failure" }; + | { type: "terminal_failure"; cancelAttemptIds?: string[] }; export type DiscoveryCoordinatorOptions = { concurrency: number; @@ -56,6 +58,7 @@ export class DiscoveryCoordinator { private attempts = new Map(); private requestEpoch = 0; private roundEpoch = 0; + private roundOpen = true; constructor(options: DiscoveryCoordinatorOptions) { this.concurrency = Math.max(1, Math.floor(options.concurrency)); @@ -67,12 +70,16 @@ export class DiscoveryCoordinator { } startStickyProbe(): void { - if (!this.isTerminal) this.state = "STICKY_PROBING"; + if (!this.isTerminal) { + this.state = "STICKY_PROBING"; + this.roundOpen = false; + } } startDiscoveryAfterSticky(): void { if (this.state === "STICKY_PROBING" || this.state === "FALLBACK_READY_HELD") { this.state = "DISCOVERY_RACING"; + this.roundOpen = true; } } @@ -82,7 +89,10 @@ export class DiscoveryCoordinator { } this.round += 1; this.roundEpoch += 1; - if (!this.isTerminal) this.state = "DISCOVERY_RACING"; + if (!this.isTerminal) { + this.state = "DISCOVERY_RACING"; + this.roundOpen = true; + } return { ...this.epochs, round: this.round }; } @@ -96,11 +106,49 @@ export class DiscoveryCoordinator { this.attempts.delete(id); } + markSetupOnly(id: string): boolean { + if (this.isTerminal) return false; + const attempt = this.attempts.get(id); + if (!attempt?.pending) return false; + attempt.setupOnly = true; + attempt.ready = false; + return true; + } + + bindSetupProvider( + id: string, + providerId: number, + priority: number, + requestEpoch = this.requestEpoch, + roundEpoch = this.roundEpoch + ): boolean { + if (!this.acceptsEpoch(requestEpoch, roundEpoch) || !this.roundOpen || this.isTerminal) { + return false; + } + const attempt = this.attempts.get(id); + if (!attempt?.pending || attempt.setupOnly !== true) return false; + attempt.providerId = providerId; + attempt.priority = priority; + return true; + } + + isSetupPending( + id: string, + requestEpoch = this.requestEpoch, + roundEpoch = this.roundEpoch + ): boolean { + if (!this.acceptsEpoch(requestEpoch, roundEpoch) || !this.roundOpen || this.isTerminal) { + return false; + } + const attempt = this.attempts.get(id); + return attempt?.pending === true && attempt.setupOnly === true; + } + /** Mark an already-running attempt as the sole fallback for this request. */ promoteToFallback(id: string): boolean { if (this.isTerminal) return false; const attempt = this.attempts.get(id); - if (!attempt?.pending) return false; + if (!attempt?.pending || attempt.setupOnly === true) return false; attempt.kind = "fallback"; this.state = "FALLBACK_READY_HELD"; return true; @@ -118,6 +166,10 @@ export class DiscoveryCoordinator { return Array.from(this.attempts.values()).filter((attempt) => attempt.pending); } + get canRefillCurrentRound(): boolean { + return this.roundOpen && !this.isTerminal; + } + get snapshot(): DiscoveryAttempt[] { return Array.from(this.attempts.values()).map((attempt) => ({ ...attempt })); } @@ -134,7 +186,7 @@ export class DiscoveryCoordinator { ): DiscoveryAction { if (!this.acceptsEpoch(requestEpoch, roundEpoch) || this.isTerminal) return { type: "none" }; const attempt = this.attempts.get(id); - if (!attempt?.pending) return { type: "none" }; + if (!attempt?.pending || attempt.setupOnly === true) return { type: "none" }; attempt.ready = true; if (attempt.kind === "fallback") { const pendingNormal = Array.from(this.attempts.values()).some( @@ -158,7 +210,8 @@ export class DiscoveryCoordinator { ): boolean { if (!this.acceptsEpoch(requestEpoch, roundEpoch) || this.isTerminal) return false; const attempt = this.attempts.get(id); - if (!attempt?.pending || attempt.kind !== "fallback") return false; + if (!attempt?.pending || attempt.setupOnly === true || attempt.kind !== "fallback") + return false; attempt.ready = true; this.state = "FALLBACK_READY_HELD"; return true; @@ -196,7 +249,13 @@ export class DiscoveryCoordinator { /** A normal ready result may win only after priority gating is satisfied. */ private chooseReadyNormal(ignorePriorityGate = false): DiscoveryAction { const readyNormal = Array.from(this.attempts.values()) - .filter((attempt) => attempt.pending && attempt.ready && attempt.kind === "normal") + .filter( + (attempt) => + attempt.pending && + attempt.setupOnly !== true && + attempt.ready && + attempt.kind === "normal" + ) .sort(compareAttempts); if (readyNormal.length === 0) return { type: "none" }; const bestPriority = readyNormal[0].priority; @@ -212,6 +271,7 @@ export class DiscoveryCoordinator { } const winner = readyNormal[0]; this.state = "WINNER_COMMITTED"; + this.roundOpen = false; winner.pending = false; return { type: "commit_normal", @@ -226,23 +286,32 @@ export class DiscoveryCoordinator { */ onRoundBoundary(requestEpoch = this.requestEpoch, roundEpoch = this.roundEpoch): DiscoveryAction { if (!this.acceptsEpoch(requestEpoch, roundEpoch) || this.isTerminal) return { type: "none" }; + this.roundOpen = false; const readyAction = this.chooseReadyNormal(true); if (readyAction.type === "commit_normal") return readyAction; + const setupAttemptIds = Array.from(this.attempts.values()) + .filter((attempt) => attempt.pending && attempt.setupOnly === true) + .map((attempt) => attempt.id); + for (const id of setupAttemptIds) this.attempts.get(id)!.pending = false; + const currentFallback = Array.from(this.attempts.values()).find( - (attempt) => attempt.pending && attempt.kind === "fallback" + (attempt) => attempt.pending && attempt.setupOnly !== true && attempt.kind === "fallback" ); if (currentFallback?.ready) { currentFallback.pending = false; this.state = "FALLBACK_ACTIVE"; + this.roundOpen = false; return { type: "promote_fallback", attemptId: currentFallback.id }; } const pendingNormal = Array.from(this.attempts.values()) - .filter((attempt) => attempt.pending && attempt.kind === "normal") + .filter( + (attempt) => attempt.pending && attempt.setupOnly !== true && attempt.kind === "normal" + ) .sort(compareAttempts); - if (currentFallback && pendingNormal.length > 0) { - const cancelAttemptIds = pendingNormal.map((attempt) => attempt.id); + if (currentFallback && (pendingNormal.length > 0 || setupAttemptIds.length > 0)) { + const cancelAttemptIds = [...pendingNormal.map((attempt) => attempt.id), ...setupAttemptIds]; for (const attempt of pendingNormal) attempt.pending = false; if (this.round < this.maxRounds) { this.beginRound(); @@ -260,13 +329,25 @@ export class DiscoveryCoordinator { this.state = "FALLBACK_READY_HELD"; return { type: "none" }; } + if (setupAttemptIds.length > 0) { + if (this.round >= this.maxRounds) { + this.state = "TERMINAL_FAILED"; + return { type: "terminal_failure", cancelAttemptIds: setupAttemptIds }; + } + this.beginRound(); + return { + type: "launch", + slots: this.concurrency, + cancelAttemptIds: setupAttemptIds, + }; + } return this.finishOrLaunch(); } const fallback = pendingNormal[0]; fallback.kind = "fallback"; this.state = "FALLBACK_READY_HELD"; - const losers = pendingNormal.slice(1).map((attempt) => attempt.id); + const losers = [...pendingNormal.slice(1).map((attempt) => attempt.id), ...setupAttemptIds]; for (const id of losers) this.attempts.get(id)!.pending = false; if (this.round < this.maxRounds) { @@ -292,9 +373,11 @@ export class DiscoveryCoordinator { if (fallback) { fallback.pending = false; this.state = "FALLBACK_ACTIVE"; + this.roundOpen = false; return { type: "promote_fallback", attemptId: fallback.id }; } this.state = "TERMINAL_FAILED"; + this.roundOpen = false; return { type: "terminal_failure" }; } @@ -303,6 +386,7 @@ export class DiscoveryCoordinator { if (!attempt || this.isTerminal) return { type: "none" }; attempt.pending = false; this.state = attempt.kind === "fallback" ? "FALLBACK_ACTIVE" : "WINNER_COMMITTED"; + this.roundOpen = false; return { type: attempt.kind === "fallback" ? "promote_fallback" : "commit_normal", attemptId: id, @@ -315,6 +399,7 @@ export class DiscoveryCoordinator { const ids = this.activeAttempts.map((attempt) => attempt.id); for (const attempt of this.attempts.values()) attempt.pending = false; this.state = "TERMINAL_FAILED"; + this.roundOpen = false; return { type: "cancel", attemptIds: ids }; } @@ -338,6 +423,7 @@ export class DiscoveryCoordinator { private finishOrLaunch(): DiscoveryAction { if (this.round >= this.maxRounds) { this.state = "TERMINAL_FAILED"; + this.roundOpen = false; return { type: "terminal_failure" }; } this.beginRound(); diff --git a/src/app/v1/_lib/proxy/discovery-validity.ts b/src/app/v1/_lib/proxy/discovery-validity.ts index 613b056eb..41481d800 100644 --- a/src/app/v1/_lib/proxy/discovery-validity.ts +++ b/src/app/v1/_lib/proxy/discovery-validity.ts @@ -86,15 +86,37 @@ function hasOpenAIResponsesOutputItem(value: unknown): boolean { } } -function classifyJson(value: unknown, protocol: DiscoveryProtocol): DiscoveryValidity { - if (!value || typeof value !== "object") return { ready: false, terminal: false, error: true }; +/** + * Protocol-level error signals that must remain terminal even if a provider + * emits a later completion marker. Keep this shared by the racing parser and + * stream finalizer so a failed winner cannot become Sticky during settlement. + */ +export function isDiscoveryProtocolErrorPayload(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; const object = value as Record; if ( object.error || object.failed || object.type === "error" || + object.type === "response.error" || object.type === "response.failed" ) { + return true; + } + + const response = object.response; + return ( + !!response && + typeof response === "object" && + !Array.isArray(response) && + !!(response as Record).error + ); +} + +function classifyJson(value: unknown, protocol: DiscoveryProtocol): DiscoveryValidity { + if (!value || typeof value !== "object") return { ready: false, terminal: false, error: true }; + const object = value as Record; + if (isDiscoveryProtocolErrorPayload(value)) { return { ready: false, terminal: true, error: true }; } if (protocol === "openai-chat") { @@ -121,7 +143,12 @@ function classifyJson(value: unknown, protocol: DiscoveryProtocol): DiscoveryVal }; } if (protocol === "gemini") { - const candidates = Array.isArray(object.candidates) ? object.candidates : []; + const response = + object.response && typeof object.response === "object" && !Array.isArray(object.response) + ? (object.response as Record) + : null; + const candidatesValue = response?.candidates ?? object.candidates; + const candidates = Array.isArray(candidatesValue) ? candidatesValue : []; return { ready: candidates.some((candidate) => hasContent(candidate)), terminal: false, diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index a3c6f23ba..6da9b1699 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -202,6 +202,7 @@ function applyProviderCustomHeaders( const RETRY_LIMITS = PROVIDER_LIMITS.MAX_RETRY_ATTEMPTS; const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环) const DISCOVERY_LEASE_HANDOFF_GRACE_SECONDS = 5; +const DISCOVERY_TERMINAL_CLEANUP_MAX_MS = 1_000; function isDiscoveryRolloutEligible(keyId: number, sessionId: string, percent: number): boolean { const normalizedPercent = Math.max(0, Math.min(100, Math.floor(percent))); @@ -241,6 +242,31 @@ class DiscoveryCancellationError extends Error { } } +type DiscoverySetupReservationBase = { + placeholderAttemptId: string; + requestEpoch: number; + roundEpoch: number; + controller: AbortController; + providerId: number | null; + providerSessionRefOwned: boolean; + providerSessionRefRetainOnSuccess: boolean; + providerSessionRefReleased: boolean; + cancellationKind: DiscoveryCancellationKind | null; +}; + +type DiscoveryRetrySetupReservation = DiscoverySetupReservationBase & { + purpose: "rectifier_retry"; + providerId: number; +}; + +type DiscoveryCandidateSetupReservation = DiscoverySetupReservationBase & { + purpose: "candidate_launch"; +}; + +type DiscoverySetupReservation = + | DiscoveryRetrySetupReservation + | DiscoveryCandidateSetupReservation; + class DiscoveryValidityLimitError extends Error { constructor() { super("Discovery response prefix exceeded the validation limit"); @@ -3964,9 +3990,13 @@ export class ProxyForwarder { const binding = await SessionManager.getSessionBindingSnapshot(sessionId, keyId); if (binding.status !== "ok") { // A foreign or irreconcilable mirror must never be mutated by this - // request. Infrastructure unavailability still falls back to the - // established legacy wrapper. - if (binding.status === "conflict") session.setSessionBindingAllowed(false); + // request or fan out through legacy Hedge. Explicit upstream failures + // may still use the established one-at-a-time provider fallback below. + // Infrastructure unavailability continues to use the legacy wrapper. + if (binding.status === "conflict") { + session.disableStreamingHedge(); + session.setSessionBindingAllowed(false); + } return null; } bindingSnapshot = binding.snapshot; @@ -5065,7 +5095,10 @@ export class ProxyForwarder { } >(); const launched = new Set(); + const retrySetupReservations = new Map(); + const candidateSetupReservations = new Map(); let sequence = 0; + let setupSequence = 0; let currentRound = 1; let winner: (typeof attempts extends Map ? V : never) | null = null; let committed = false; @@ -5077,9 +5110,15 @@ export class ProxyForwarder { let roundTimer: NodeJS.Timeout | null = null; let stickyTimer: NodeJS.Timeout | null = null; let roundLaunchesInProgress = 0; + let queuedRoundLaunchesPending = 0; + let refillQueue: Promise = Promise.resolve(); const roundLaunchIdleWaiters = new Set<() => void>(); + const queuedRoundLaunchStartWaiters = new Set<() => void>(); let fallbackPromotionBlocked = false; - let stickyTimeoutWaveReservation: { fallbackAttemptId: string } | null = null; + type StickyTimeoutWaveReservation = { fallbackAttemptId: string; slots: number }; + let stickyTimeoutWaveReservation: StickyTimeoutWaveReservation | null = null; + let stickyTimeoutWaveClaim: StickyTimeoutWaveReservation | null = null; + let stickyTimeoutWaveLaunchPromise: Promise | null = null; let stickyTimeoutCooldownPromise: Promise | null = null; const hasSticky = session.shouldReuseProvider() && @@ -5096,6 +5135,15 @@ export class ProxyForwarder { for (const resolve of roundLaunchIdleWaiters) resolve(); roundLaunchIdleWaiters.clear(); }; + const waitForRoundLaunchHandoff = async (): Promise => { + // A queued wave only needs to register its setup placeholders before a + // failure can safely update the coordinator. Waiting for that wave's + // selector/admission/setup to finish would keep a dead fallback occupying + // a slot until the round SLA and prevent immediate error replacement. + while (queuedRoundLaunchesPending > 0) { + await new Promise((resolve) => queuedRoundLaunchStartWaiters.add(resolve)); + } + }; const clearRoundTimer = () => { if (roundTimer) { clearTimeout(roundTimer); @@ -5117,6 +5165,82 @@ export class ProxyForwarder { ProxyForwarder.releaseProviderSessionRef(session, attempt.provider.id); }; + const releaseSetupProviderRef = (reservation: DiscoverySetupReservation) => { + if ( + reservation.providerId == null || + !reservation.providerSessionRefOwned || + reservation.providerSessionRefReleased + ) { + return; + } + reservation.providerSessionRefReleased = true; + reservation.providerSessionRefOwned = false; + ProxyForwarder.releaseProviderSessionRef(session, reservation.providerId); + }; + + const cancelSetupReservation = ( + reservation: DiscoverySetupReservation, + cancellationKind: DiscoveryCancellationKind + ) => { + if (reservation.cancellationKind) return; + reservation.cancellationKind = cancellationKind; + if (reservation.purpose === "rectifier_retry") { + retrySetupReservations.delete(reservation.placeholderAttemptId); + } else { + candidateSetupReservations.delete(reservation.placeholderAttemptId); + } + coordinator.removeAttempt(reservation.placeholderAttemptId); + if (!reservation.controller.signal.aborted) { + try { + reservation.controller.abort(new DiscoveryCancellationError(cancellationKind)); + } catch { + /* abort is best effort */ + } + } + releaseSetupProviderRef(reservation); + }; + + const cancelSetupReservations = ( + cancellationKind: DiscoveryCancellationKind, + predicate: (reservation: DiscoverySetupReservation) => boolean = () => true + ) => { + for (const reservation of retrySetupReservations.values()) { + if (predicate(reservation)) cancelSetupReservation(reservation, cancellationKind); + } + for (const reservation of candidateSetupReservations.values()) { + if (predicate(reservation)) cancelSetupReservation(reservation, cancellationKind); + } + }; + + const awaitSetupStep = async ( + promise: Promise, + reservation?: DiscoverySetupReservation + ): Promise => { + if (!reservation) return promise; + const { signal } = reservation.controller; + if (signal.aborted) { + throw signal.reason ?? new DiscoveryCancellationError("discovery_sla_timeout"); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + cleanup(); + reject(signal.reason ?? new DiscoveryCancellationError("discovery_sla_timeout")); + }; + const cleanup = () => signal.removeEventListener("abort", onAbort); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + } + ); + }); + }; + const getAttemptModelRedirect = (attempt: (typeof winner & { id: string }) | null) => { if (!attempt) return undefined; if (attempt.modelRedirect !== undefined) return attempt.modelRedirect; @@ -5191,6 +5315,43 @@ export class ProxyForwarder { } }; + const waitForStickyTimeoutCooldownBounded = async ( + cancellationKind?: DiscoveryCancellationKind + ): Promise => { + const cooldown = stickyTimeoutCooldownPromise; + if (!cooldown) return; + + const maxWaitMs = + cancellationKind === "client_abort" + ? 0 + : cancellationKind === "request_deadline" + ? DISCOVERY_TERMINAL_CLEANUP_MAX_MS + : Math.min( + DISCOVERY_TERMINAL_CLEANUP_MAX_MS, + Math.max(0, racingDeadlineAt - Date.now()) + ); + if (maxWaitMs <= 0) return; + + let timeout: NodeJS.Timeout | null = null; + let completed = false; + await Promise.race([ + cooldown.then(() => { + completed = true; + }), + new Promise((resolve) => { + timeout = setTimeout(resolve, maxWaitMs); + timeout.unref?.(); + }), + ]); + if (timeout) clearTimeout(timeout); + if (!completed) { + logger.warn("[Discovery] Sticky cooldown cleanup exceeded terminal wait budget", { + maxWaitMs, + cancellationKind, + }); + } + }; + const settleFailure = async ( error: Error, options: { @@ -5203,12 +5364,19 @@ export class ProxyForwarder { if (totalTimer) clearTimeout(totalTimer); if (roundTimer) clearTimeout(roundTimer); if (stickyTimer) clearTimeout(stickyTimer); + cancelSetupReservations(options.cancellationKind ?? "discovery_loser"); cancelLosers(null, options.cancellationKind ?? "discovery_loser"); // Sticky timeout owns its cooldown mutation. A terminal deadline/error or // client abort may race that Redis CAS, but must not issue a second - // zero-cooldown clear against the same captured generation. - if (stickyTimeoutCooldownPromise) await stickyTimeoutCooldownPromise; - if (!options.preserveBinding && bindingWriteAllowed && session.isSessionBindingAllowed()) { + // zero-cooldown clear against the same captured generation. Terminal + // delivery must not wait without a bound for Redis cleanup. + await waitForStickyTimeoutCooldownBounded(options.cancellationKind); + if ( + !stickyTimeoutCooldownPromise && + !options.preserveBinding && + bindingWriteAllowed && + session.isSessionBindingAllowed() + ) { if (bindingSnapshot.providerId != null) { await SessionManager.clearVersionedSessionProvider( bindingSnapshot, @@ -5257,6 +5425,7 @@ export class ProxyForwarder { if (totalTimer) clearTimeout(totalTimer); if (roundTimer) clearTimeout(roundTimer); if (stickyTimer) clearTimeout(stickyTimer); + cancelSetupReservations("discovery_loser"); cancelLosers(attempt); discoveryMetrics.attemptFinished(attempt.id, { providerId: attempt.provider.id, @@ -5396,41 +5565,99 @@ export class ProxyForwarder { attemptSession?: ProxySession; requestAttemptCount?: number; retryState?: ReactiveRectifierRetryState; - providerSessionRefTransfer?: { - owned: boolean; - retainOnSuccess: boolean; - }; + retrySetupReservation?: DiscoveryRetrySetupReservation; + candidateSetupReservation?: DiscoveryCandidateSetupReservation; } ): Promise => { - const transferredProviderSessionRef = options?.providerSessionRefTransfer?.owned === true; - let providerSessionRefTracked = transferredProviderSessionRef; + const retrySetupReservation = options?.retrySetupReservation; + const candidateSetupReservation = options?.candidateSetupReservation; + const setupReservation = retrySetupReservation ?? candidateSetupReservation; + const isCandidateSetupActive = () => + !candidateSetupReservation || + (candidateSetupReservations.get(candidateSetupReservation.placeholderAttemptId) === + candidateSetupReservation && + !candidateSetupReservation.controller.signal.aborted && + coordinator.isSetupPending( + candidateSetupReservation.placeholderAttemptId, + candidateSetupReservation.requestEpoch, + candidateSetupReservation.roundEpoch + )); + let providerSessionRefOwnedByReservation = + setupReservation?.providerSessionRefOwned === true && + setupReservation.providerSessionRefReleased === false; + let providerSessionRefTracked = providerSessionRefOwnedByReservation; let providerSessionRefRetainOnSuccess = - options?.providerSessionRefTransfer?.retainOnSuccess === true; + setupReservation?.providerSessionRefRetainOnSuccess === true; const rollbackLaunch = () => { if (providerSessionRefTracked) { - ProxyForwarder.releaseProviderSessionRef(session, provider.id); + if (providerSessionRefOwnedByReservation && setupReservation) { + releaseSetupProviderRef(setupReservation); + } else { + ProxyForwarder.releaseProviderSessionRef(session, provider.id); + } providerSessionRefTracked = false; + providerSessionRefOwnedByReservation = false; providerSessionRefRetainOnSuccess = false; } + if (retrySetupReservation) { + retrySetupReservations.delete(retrySetupReservation.placeholderAttemptId); + } + if (candidateSetupReservation && !candidateSetupReservation.cancellationKind) { + candidateSetupReservation.providerId = null; + candidateSetupReservation.providerSessionRefOwned = false; + candidateSetupReservation.providerSessionRefRetainOnSuccess = false; + candidateSetupReservation.providerSessionRefReleased = false; + } }; - if (settled || committed || launched.has(provider.id)) { + if ( + settled || + committed || + setupReservation?.controller.signal.aborted || + !isCandidateSetupActive() || + (!retrySetupReservation && launched.has(provider.id)) + ) { rollbackLaunch(); return false; } - launched.add(provider.id); - if (!transferredProviderSessionRef && provider.id === initialProvider.id) { + if (!retrySetupReservation) launched.add(provider.id); + if (!setupReservation && provider.id === initialProvider.id) { providerSessionRefTracked = session.hasProviderSessionRef(provider.id); providerSessionRefRetainOnSuccess = providerSessionRefTracked && session.shouldRetainProviderSessionRefOnSuccess(provider.id); } - if (!providerSessionRefTracked && session.sessionId) { + if (!retrySetupReservation && !providerSessionRefTracked && session.sessionId) { const limit = provider.limitConcurrentSessions || 0; - const check = await RateLimitService.checkAndTrackProviderSession( + const admissionPromise = RateLimitService.checkAndTrackProviderSession( provider.id, session.sessionId, limit ); + // The underlying admission call cannot be aborted. If cancellation + // wins its race and the late result nevertheless reserved a session + // ref, release that ref here; the normal path transfers ownership to + // the reservation and remains exactly-once. + if (setupReservation) { + void admissionPromise.then( + (lateCheck) => { + if (setupReservation.controller.signal.aborted && lateCheck.referenced) { + session.recordProviderSessionRef(provider.id, { + retainOnSuccess: lateCheck.tracked, + }); + ProxyForwarder.releaseProviderSessionRef(session, provider.id); + } + }, + () => undefined + ); + } + let check: Awaited>; + try { + check = await awaitSetupStep(admissionPromise, setupReservation); + } catch (error) { + rollbackLaunch(); + throw error; + } if (!check.allowed) { + rollbackLaunch(); throw new ProxyError(check.reason || "Provider concurrent limit reached", 503); } if (check.referenced) { @@ -5439,20 +5666,35 @@ export class ProxyForwarder { }); providerSessionRefTracked = true; providerSessionRefRetainOnSuccess = check.tracked; + if (candidateSetupReservation) { + candidateSetupReservation.providerId = provider.id; + candidateSetupReservation.providerSessionRefOwned = true; + candidateSetupReservation.providerSessionRefRetainOnSuccess = check.tracked; + candidateSetupReservation.providerSessionRefReleased = false; + providerSessionRefOwnedByReservation = true; + } } } - if (settled || committed) { + if (settled || committed || !isCandidateSetupActive()) { rollbackLaunch(); return false; } let endpoint: Awaited>; try { - endpoint = await ProxyForwarder.resolveStreamingHedgeEndpoint(session, provider); + endpoint = await awaitSetupStep( + ProxyForwarder.resolveStreamingHedgeEndpoint(session, provider), + setupReservation + ); } catch (error) { rollbackLaunch(); throw error; } - if (settled || committed) { + if ( + settled || + committed || + setupReservation?.controller.signal.aborted || + !isCandidateSetupActive() + ) { rollbackLaunch(); return false; } @@ -5468,15 +5710,47 @@ export class ProxyForwarder { rollbackLaunch(); throw error; } - if (settled || committed) { + if ( + settled || + committed || + setupReservation?.controller.signal.aborted || + !isCandidateSetupActive() + ) { rollbackLaunch(); return false; } + let effectiveKind = kind; + if (retrySetupReservation) { + const reservedAttempt = coordinator.snapshot.find( + (candidate) => candidate.id === retrySetupReservation.placeholderAttemptId + ); + if (!reservedAttempt?.pending) { + rollbackLaunch(); + return false; + } + effectiveKind = reservedAttempt.kind; + coordinator.removeAttempt(retrySetupReservation.placeholderAttemptId); + if (providerSessionRefOwnedByReservation) { + retrySetupReservation.providerSessionRefOwned = false; + providerSessionRefOwnedByReservation = false; + } + } else if (candidateSetupReservation) { + if (!isCandidateSetupActive()) { + rollbackLaunch(); + return false; + } + coordinator.removeAttempt(candidateSetupReservation.placeholderAttemptId); + candidateSetupReservations.delete(candidateSetupReservation.placeholderAttemptId); + if (providerSessionRefOwnedByReservation) { + candidateSetupReservation.providerSessionRefOwned = false; + providerSessionRefOwnedByReservation = false; + } + } const controller = new AbortController(); const id = `${provider.id}:${sequence + 1}`; const attempt = { id, - kind, + kind: effectiveKind, controller, parser: new DiscoveryValidityParser(protocol), chunks: [], @@ -5536,25 +5810,32 @@ export class ProxyForwarder { id, providerId: provider.id, priority: ProxyProviderResolver.resolveEffectivePriorityForSession(provider, session), - kind, + kind: effectiveKind, ready: false, pending: true, round: currentRound, launchOrder: attempt.sequence, }); if (!registered || settled || committed) { + if (candidateSetupReservation) { + candidateSetupReservations.delete(candidateSetupReservation.placeholderAttemptId); + } rollbackLaunch(); return false; } + if (retrySetupReservation) { + retrySetupReservations.delete(retrySetupReservation.placeholderAttemptId); + attempts.delete(retrySetupReservation.placeholderAttemptId); + } attempts.set(id, attempt); discoveryMetrics.attemptStarted({ attemptId: id, providerId: provider.id, round: - stickyProbeActive && provider.id === initialProvider.id && kind === "normal" + stickyProbeActive && provider.id === initialProvider.id && effectiveKind === "normal" ? 0 : currentRound, - kind, + kind: effectiveKind, }); void ProxyForwarder.doForward( @@ -5611,7 +5892,9 @@ export class ProxyForwarder { attempt.ready = true; if ( attempt.kind === "fallback" && - (fallbackPromotionBlocked || roundLaunchesInProgress > 0) + (fallbackPromotionBlocked || + roundLaunchesInProgress > 0 || + queuedRoundLaunchesPending > 0) ) { // The next wave has been reserved but its normal attempts may // still be awaiting selection/endpoint setup. Persist readiness @@ -5638,6 +5921,15 @@ export class ProxyForwarder { }) .catch(async (error) => { if (committed || settled || !attempt.pending) return; + const stickyWaveForFallback = + attempt.kind === "fallback" + ? stickyTimeoutWaveReservation?.fallbackAttemptId === id + ? stickyTimeoutWaveReservation + : stickyTimeoutWaveClaim?.fallbackAttemptId === id + ? stickyTimeoutWaveClaim + : null + : null; + if (stickyWaveForFallback) stickyWaveForFallback.slots = concurrency; lastError = error instanceof Error ? error : new Error(String(error)); lastErrorCategory = await categorizeErrorAsync(lastError); const errorMessage = @@ -5700,7 +5992,7 @@ export class ProxyForwarder { // that is meant to replace it. Wait until those reserved slots have // either registered or rolled back before the coordinator decides // whether another round is needed. - await waitForRoundLaunches(); + await waitForRoundLaunchHandoff(); if (committed || settled || !attempt.pending) return; // Preserve the existing provider-local rectifier contract before @@ -5708,6 +6000,7 @@ export class ProxyForwarder { // request session, so retry the same attempt session rather than // creating a fresh unrectified shadow from the parent session. const rectifier = await tryApplyReactiveRectifier({ + error: lastError, provider, requestSession: attempt.session, persistSession: session, @@ -5717,16 +6010,29 @@ export class ProxyForwarder { retryState: attempt.reactiveRectifierRetryState, }); if (rectifier.matched && rectifier.applied) { - const providerSessionRefTransfer = { - owned: attempt.providerSessionRefOwned && !attempt.providerSessionRefReleased, - retainOnSuccess: attempt.providerSessionRefRetainOnSuccess, + const reservationEpoch = coordinator.epochs; + const retrySetupReservation: DiscoveryRetrySetupReservation = { + purpose: "rectifier_retry", + placeholderAttemptId: id, + providerId: provider.id, + requestEpoch: reservationEpoch.requestEpoch, + roundEpoch: reservationEpoch.roundEpoch, + controller: new AbortController(), + providerSessionRefOwned: + attempt.providerSessionRefOwned && !attempt.providerSessionRefReleased, + providerSessionRefRetainOnSuccess: attempt.providerSessionRefRetainOnSuccess, + providerSessionRefReleased: false, + cancellationKind: null, }; - // The provider-local retry keeps the same concurrency slot. Move - // ownership to the replacement launch before cleaning the failed - // transport so there is no release/reacquire race window. - if (providerSessionRefTransfer.owned) attempt.providerSessionRefOwned = false; + retrySetupReservations.set(id, retrySetupReservation); + coordinator.markSetupOnly(id); + // Keep the coordinator placeholder alive while setup runs. It + // reserves the same concurrency slot across round transitions, + // while the reservation owns the transferred Provider ref. + if (retrySetupReservation.providerSessionRefOwned) { + attempt.providerSessionRefOwned = false; + } attempt.pending = false; - coordinator.removeAttempt(id); cleanupAttempt(attempt, null); session.addProviderToChain(provider, { ...buildRetryFailedChainEntry( @@ -5740,45 +6046,96 @@ export class ProxyForwarder { ), modelRedirect: getAttemptModelRedirect(attempt), }); - launched.delete(provider.id); try { await launch(provider, attempt.kind, { attemptSession: attempt.session, requestAttemptCount: attempt.requestAttemptCount + 1, retryState: attempt.reactiveRectifierRetryState, - providerSessionRefTransfer, + retrySetupReservation, }); } catch (retryLaunchError) { + if (retrySetupReservation.cancellationKind || committed || settled) return; lastError = retryLaunchError instanceof Error ? retryLaunchError : new Error(String(retryLaunchError)); lastErrorCategory = await categorizeErrorAsync(lastError); + if (lastErrorCategory === ErrorCategory.CLIENT_ABORT) { + coordinator.cancelRequest(); + await settleFailure( + lastError instanceof ProxyError + ? lastError + : new ProxyError("Request aborted by client", 499, undefined, true), + { preserveBinding: true, cancellationKind: "client_abort" } + ); + return; + } + if (lastErrorCategory === ErrorCategory.LOCAL_OVERLOAD) { + await settleFailure(lastError, { preserveBinding: true }); + return; + } if (stickyProbeActive && provider.id === initialProvider.id) { stickyProbeActive = false; if (stickyTimer) { clearTimeout(stickyTimer); stickyTimer = null; } + coordinator.removeAttempt(id); await clearCapturedStickyBinding(0); coordinator.startDiscoveryAfterSticky(); await launchNextRound(concurrency, true); return; } - await settleFailure( - ProxyForwarder.resolveHedgeTerminalError(lastError, lastErrorCategory) - ); + + if (stickyTimeoutWaveReservation?.fallbackAttemptId === id) { + // Sticky already timed out, but its replacement wave is still + // waiting on the binding cooldown. A retry setup failure frees + // the fallback slot, so enlarge the reserved wave to full + // concurrency; the cooldown completion callback still owns + // starting it. + stickyTimeoutWaveReservation.slots = concurrency; + coordinator.removeAttempt(id); + if (!stickyTimeoutCooldownPromise) { + await launchReservedStickyTimeoutWave(); + } + return; + } + + // The rectified retry is provider-local. A setup failure must + // release only this attempt's slot and let other Discovery + // candidates continue. The placeholder is still in the + // coordinator, so its normal failure transition is sufficient. + const retryFailureAction = coordinator.markFailed(id); + const actionOwnsNextStep = + retryFailureAction.type === "commit_normal" || + retryFailureAction.type === "promote_fallback" || + retryFailureAction.type === "launch" || + retryFailureAction.type === "terminal_failure"; + if (actionOwnsNextStep) { + await executeCoordinatorAction(retryFailureAction); + } + if (!actionOwnsNextStep && !committed && !settled) { + await refillCurrentRoundSlots(concurrency); + } + if (coordinator.activeAttempts.length === 0 && noMoreCandidates) { + await settleFailure( + ProxyForwarder.resolveHedgeTerminalError(lastError, lastErrorCategory) + ); + } } return; } const failedStickyProbe = stickyProbeActive && attempt.kind === "normal" && provider.id === initialProvider.id; + const failedReservedStickyFallback = + !failedStickyProbe && stickyTimeoutWaveReservation?.fallbackAttemptId === id; attempt.pending = false; - const failureAction = failedStickyProbe - ? ({ type: "none" } as const) - : coordinator.markFailed(id); - if (failedStickyProbe) coordinator.removeAttempt(id); + const failureAction = + failedStickyProbe || failedReservedStickyFallback + ? ({ type: "none" } as const) + : coordinator.markFailed(id); + if (failedStickyProbe || failedReservedStickyFallback) coordinator.removeAttempt(id); session.addProviderToChain(provider, { ...attempt.endpointAudit, reason: "retry_failed", @@ -5815,6 +6172,16 @@ export class ProxyForwarder { await launchNextRound(concurrency, true); return; } + if (failedReservedStickyFallback && stickyTimeoutWaveReservation) { + // This fallback represented the only occupied slot in the reserved + // Sticky replacement wave. Do not let markFailed terminalize a + // maxRounds=1 coordinator before that first wave has started. + stickyTimeoutWaveReservation.slots = concurrency; + if (!stickyTimeoutCooldownPromise) { + await launchReservedStickyTimeoutWave(); + } + return; + } const actionOwnsNextStep = failureAction.type === "commit_normal" || failureAction.type === "promote_fallback" || @@ -5825,7 +6192,15 @@ export class ProxyForwarder { failureAction.type === "launch" && stickyTimeoutWaveReservation?.fallbackAttemptId === id ) { - await launchReservedStickyTimeoutWave(failureAction.slots); + stickyTimeoutWaveReservation.slots = Math.max( + stickyTimeoutWaveReservation.slots, + failureAction.slots + ); + if (!stickyTimeoutCooldownPromise) { + await launchReservedStickyTimeoutWave(); + } + } else if (failureAction.type === "launch" && stickyTimeoutWaveLaunchPromise) { + await stickyTimeoutWaveLaunchPromise; } else { await executeCoordinatorAction(failureAction); } @@ -5833,10 +6208,7 @@ export class ProxyForwarder { if (!actionOwnsNextStep && !committed && !settled) { await refillCurrentRoundSlots(1); } - if ( - Array.from(attempts.values()).every((candidate) => !candidate.pending) && - noMoreCandidates - ) { + if (coordinator.activeAttempts.length === 0 && noMoreCandidates) { await settleFailure( ProxyForwarder.resolveHedgeTerminalError(lastError, lastErrorCategory) ); @@ -5851,52 +6223,152 @@ export class ProxyForwarder { return true; }; - const fillDiscoverySlots = async (slots: number): Promise => { - let remainingSlots = slots; - while (remainingSlots > 0 && !settled && !committed) { - const exclusionCountBeforeSelection = launched.size; - const candidates = await ProxyProviderResolver.pickDiscoveryProviders( - session, - remainingSlots, - Array.from(launched) - ); - if (candidates.length === 0) { - noMoreCandidates = true; - break; - } + const reserveCandidateSetupSlots = (slots: number): DiscoveryCandidateSetupReservation[] => { + const epoch = coordinator.epochs; + const reservations: DiscoveryCandidateSetupReservation[] = []; + for (let index = 0; index < slots; index += 1) { + const placeholderAttemptId = `setup:${currentRound}:${++setupSequence}`; + const reservation: DiscoveryCandidateSetupReservation = { + purpose: "candidate_launch", + placeholderAttemptId, + requestEpoch: epoch.requestEpoch, + roundEpoch: epoch.roundEpoch, + controller: new AbortController(), + providerId: null, + providerSessionRefOwned: false, + providerSessionRefRetainOnSuccess: false, + providerSessionRefReleased: false, + cancellationKind: null, + }; + const registered = coordinator.addAttempt({ + id: placeholderAttemptId, + providerId: 0, + priority: Number.POSITIVE_INFINITY, + kind: "normal", + setupOnly: true, + ready: false, + pending: true, + round: currentRound, + launchOrder: Number.MAX_SAFE_INTEGER - slots + index, + }); + if (!registered) break; + candidateSetupReservations.set(placeholderAttemptId, reservation); + reservations.push(reservation); + } + return reservations; + }; + + const isCandidateSetupReservationActive = ( + reservation: DiscoveryCandidateSetupReservation + ): boolean => + candidateSetupReservations.get(reservation.placeholderAttemptId) === reservation && + !reservation.controller.signal.aborted && + coordinator.isSetupPending( + reservation.placeholderAttemptId, + reservation.requestEpoch, + reservation.roundEpoch + ); - noMoreCandidates = false; - let registeredInBatch = 0; - for (const candidate of candidates) { + const fillDiscoverySlots = async (slots: number): Promise => { + const reservations = reserveCandidateSetupSlots(slots); + try { + while (reservations.length > 0 && !settled && !committed) { + if (!isCandidateSetupReservationActive(reservations[0])) break; + const exclusionCountBeforeSelection = launched.size; + let candidates: Provider[]; try { - if (await launch(candidate, "normal")) { - registeredInBatch += 1; - remainingSlots -= 1; - } + candidates = await awaitSetupStep( + ProxyProviderResolver.pickDiscoveryProviders( + session, + reservations.length, + Array.from(launched) + ), + reservations[0] + ); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - // launch() excludes setup failures before throwing, so keep filling - // this round from the remaining candidate pool. - noMoreCandidates = false; + if (!isCandidateSetupReservationActive(reservations[0])) break; + throw error; + } + if ( + settled || + committed || + reservations.length === 0 || + !isCandidateSetupReservationActive(reservations[0]) + ) { + break; + } + if (candidates.length === 0) { + noMoreCandidates = true; + break; + } + + noMoreCandidates = false; + let registeredInBatch = 0; + for (const candidate of candidates) { + const reservation = reservations[0]; + if (!reservation || !isCandidateSetupReservationActive(reservation)) break; + reservation.providerId = candidate.id; + const bound = coordinator.bindSetupProvider( + reservation.placeholderAttemptId, + candidate.id, + ProxyProviderResolver.resolveEffectivePriorityForSession(candidate, session), + reservation.requestEpoch, + reservation.roundEpoch + ); + if (!bound) break; + try { + if ( + await launch(candidate, "normal", { + candidateSetupReservation: reservation, + }) + ) { + registeredInBatch += 1; + reservations.shift(); + } + } catch (error) { + if (isCandidateSetupReservationActive(reservation)) { + lastError = error instanceof Error ? error : new Error(String(error)); + // The setup placeholder remains in the current round so another + // candidate can consume the same reserved slot. + noMoreCandidates = false; + } + } + if (reservations.length === 0 || settled || committed) break; } - if (remainingSlots <= 0 || settled || committed) break; - } - // A selector that ignores exclusions must not create an unbounded setup - // loop. Normal selectors advance `launched` even before transport setup. - if (registeredInBatch === 0 && launched.size === exclusionCountBeforeSelection) { - noMoreCandidates = true; - break; + if (reservations.length === 0 || !isCandidateSetupReservationActive(reservations[0])) { + break; + } + // A selector that ignores exclusions must not create an unbounded setup + // loop. Normal selectors advance `launched` even before transport setup. + if (registeredInBatch === 0 && launched.size === exclusionCountBeforeSelection) { + noMoreCandidates = true; + break; + } + } + } finally { + for (const reservation of reservations) { + if (candidateSetupReservations.get(reservation.placeholderAttemptId) === reservation) { + cancelSetupReservation(reservation, "discovery_loser"); + } } } }; - const finishRoundLaunchBatch = async (): Promise => { - roundLaunchesInProgress = Math.max(0, roundLaunchesInProgress - 1); - if (roundLaunchesInProgress !== 0) return; - - notifyRoundLaunchIdle(); + const reevaluateReadyAttemptsAfterLaunch = async (): Promise => { + if (roundLaunchesInProgress !== 0 || queuedRoundLaunchesPending !== 0) return; + if (committed || settled) return; fallbackPromotionBlocked = false; + const readyNormal = Array.from(attempts.values()).find( + (attempt) => attempt.pending && attempt.ready && attempt.kind === "normal" + ); + if (readyNormal && !committed && !settled) { + const action = coordinator.markReady(readyNormal.id); + if (action.type === "commit_normal") { + await commit(attempts.get(action.attemptId) ?? null); + return; + } + } const readyFallback = Array.from(attempts.values()).find( (attempt) => attempt.pending && attempt.ready && attempt.kind === "fallback" ); @@ -5906,22 +6378,67 @@ export class ProxyForwarder { } }; + const finishRoundLaunchBatch = async (): Promise => { + roundLaunchesInProgress = Math.max(0, roundLaunchesInProgress - 1); + if (roundLaunchesInProgress !== 0) return; + + notifyRoundLaunchIdle(); + await reevaluateReadyAttemptsAfterLaunch(); + }; + async function refillCurrentRoundSlots(slots: number): Promise { if (slots <= 0 || settled || committed) return; - roundLaunchesInProgress += 1; - try { - // Deliberately preserve the existing round timer. An explicit failure - // releases capacity but must not grant the replacement a fresh SLA. - await fillDiscoverySlots(slots); - } finally { - await finishRoundLaunchBatch(); - } + const refill = async () => { + if (settled || committed || !coordinator.canRefillCurrentRound) return; + const activeOrReserved = coordinator.activeAttempts.length; + const availableSlots = Math.max(0, concurrency - activeOrReserved); + const requestedSlots = Math.min(slots, availableSlots); + if (requestedSlots <= 0) return; + roundLaunchesInProgress += 1; + try { + // Deliberately preserve the existing round timer. An explicit failure + // releases capacity but must not grant the replacement a fresh SLA. + await fillDiscoverySlots(requestedSlots); + } finally { + await finishRoundLaunchBatch(); + } + }; + const queued = refillQueue.then(refill, refill); + refillQueue = queued.catch(() => undefined); + await queued; } - const launchNextRound = async (slots: number, coordinatorAlreadyAdvanced = false) => { - if (settled || committed) return; + let launchNextRoundQueue: Promise = Promise.resolve(); + type RoundLaunchSlotState = { + resolvers: Set<() => number>; + consumedSlots: number; + timerStarted: boolean; + }; + type QueuedRoundLaunch = { + slotState: RoundLaunchSlotState; + onStartCallbacks: Set<() => void>; + started: boolean; + accepting: boolean; + gateReleased: boolean; + promise: Promise; + }; + const queuedAdvancedRoundLaunches = new Map(); + const runLaunchNextRound = async ( + slotState: RoundLaunchSlotState, + coordinatorAlreadyAdvanced = false, + onSlotsClosed?: () => void + ) => { + let slotsClosed = false; + const closeSlots = () => { + if (slotsClosed) return; + slotsClosed = true; + onSlotsClosed?.(); + }; + if (settled || committed) { + closeSlots(); + return; + } roundLaunchesInProgress += 1; - clearRoundTimer(); try { if (coordinatorAlreadyAdvanced) { currentRound = coordinator.round; @@ -5929,47 +6446,221 @@ export class ProxyForwarder { const nextRound = coordinator.beginRound(); currentRound = nextRound.round; } - if (currentRound > maxRounds || slots <= 0) return; - await fillDiscoverySlots(slots); - const hasPendingAttempt = Array.from(attempts.values()).some((attempt) => attempt.pending); - if (!hasPendingAttempt) { - await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); + const launchEpoch = coordinator.epochs; + const requestedSlots = () => + Math.max(0, ...Array.from(slotState.resolvers, (slotResolver) => slotResolver())); + if (currentRound > maxRounds || requestedSlots() <= slotState.consumedSlots) { + closeSlots(); return; } - if (!committed && !settled) { + // The round SLA starts when the round is opened, before selector, + // admission, or endpoint setup can consume the budget. The timer + // cancels setup reservations and advances the coordinator while the + // launch batch is still in flight. + if (!slotState.timerStarted && !committed && !settled) { + clearRoundTimer(); scheduleRoundBoundary(discoverySlaMs); + slotState.timerStarted = true; + } + while ( + !committed && + !settled && + coordinator.acceptsEpoch(launchEpoch.requestEpoch, launchEpoch.roundEpoch) + ) { + const targetSlots = requestedSlots(); + const additionalSlots = targetSlots - slotState.consumedSlots; + if (additionalSlots <= 0) { + closeSlots(); + break; + } + slotState.consumedSlots = targetSlots; + await fillDiscoverySlots(additionalSlots); + } + closeSlots(); + const hasPendingAttempt = coordinator.activeAttempts.length > 0; + if ( + !hasPendingAttempt && + !committed && + !settled && + coordinator.acceptsEpoch(launchEpoch.requestEpoch, launchEpoch.roundEpoch) + ) { + await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); + return; } } finally { + closeSlots(); await finishRoundLaunchBatch(); } }; - const launchReservedStickyTimeoutWave = async (slots: number): Promise => { - if (!stickyTimeoutWaveReservation) return; - stickyTimeoutWaveReservation = null; + const queueLaunchNextRound = async ( + resolveSlots: () => number, + coordinatorAlreadyAdvanced = false, + onStart?: () => void + ) => { + const expectedEpoch = coordinatorAlreadyAdvanced ? coordinator.epochs : null; + const epochKey = expectedEpoch + ? `${expectedEpoch.requestEpoch}:${expectedEpoch.roundEpoch}` + : null; + const existing = epochKey ? queuedAdvancedRoundLaunches.get(epochKey) : null; + if (existing?.accepting) { + existing.slotState.resolvers.add(resolveSlots); + if (onStart) { + if (existing.started) onStart(); + else existing.onStartCallbacks.add(onStart); + } + return existing.promise; + } + + const slotState: RoundLaunchSlotState = existing?.slotState ?? { + resolvers: new Set(), + consumedSlots: 0, + timerStarted: false, + }; + slotState.resolvers.add(resolveSlots); + const launchEntry: QueuedRoundLaunch = { + slotState, + onStartCallbacks: new Set(onStart ? [onStart] : []), + started: false, + accepting: true, + gateReleased: false, + promise: Promise.resolve(), + }; + queuedRoundLaunchesPending += 1; + const releaseQueuedWaveGate = async () => { + if (launchEntry.gateReleased) return; + launchEntry.gateReleased = true; + queuedRoundLaunchesPending = Math.max(0, queuedRoundLaunchesPending - 1); + if (queuedRoundLaunchesPending === 0) { + for (const resolve of queuedRoundLaunchStartWaiters) resolve(); + queuedRoundLaunchStartWaiters.clear(); + } + await reevaluateReadyAttemptsAfterLaunch(); + }; + const runQueuedLaunch = async () => { + // A boundary may cancel a selector/setup batch before enqueueing the + // next wave. Wait for that batch's finally/cleanup before opening the + // next round so two waves cannot overlap or double-count slots. + await waitForRoundLaunches(); + if ( + expectedEpoch && + !coordinator.acceptsEpoch(expectedEpoch.requestEpoch, expectedEpoch.roundEpoch) + ) { + await releaseQueuedWaveGate(); + return; + } + launchEntry.started = true; + for (const callback of launchEntry.onStartCallbacks) callback(); + launchEntry.onStartCallbacks.clear(); + const running = runLaunchNextRound( + launchEntry.slotState, + coordinatorAlreadyAdvanced, + () => { + launchEntry.accepting = false; + } + ); + // runLaunchNextRound synchronously registers setup reservations before + // its first await, so the queued-wave gate can now hand off to the + // coordinator's active-attempt gate. + await releaseQueuedWaveGate(); + return running; + }; + const queued = launchNextRoundQueue.then(runQueuedLaunch, runQueuedLaunch); + launchEntry.promise = queued; + if (epochKey) queuedAdvancedRoundLaunches.set(epochKey, launchEntry); + launchNextRoundQueue = queued.catch(() => undefined); + void queued.then( + () => { + if (epochKey && queuedAdvancedRoundLaunches.get(epochKey) === launchEntry) { + queuedAdvancedRoundLaunches.delete(epochKey); + } + }, + () => { + if (epochKey && queuedAdvancedRoundLaunches.get(epochKey) === launchEntry) { + queuedAdvancedRoundLaunches.delete(epochKey); + } + } + ); + return queued; + }; + + const launchNextRound = async (slots: number, coordinatorAlreadyAdvanced = false) => + queueLaunchNextRound(() => slots, coordinatorAlreadyAdvanced); + + const launchReservedStickyTimeoutWave = async (slots?: number): Promise => { + const reservation = stickyTimeoutWaveReservation; + if (reservation && slots != null) reservation.slots = Math.max(reservation.slots, slots); + if (stickyTimeoutWaveLaunchPromise) return stickyTimeoutWaveLaunchPromise; + if (!reservation) return; if (settled || committed) return; - await launchNextRound(slots, true); + stickyTimeoutWaveClaim = reservation; + const launchPromise = queueLaunchNextRound( + () => reservation.slots, + true, + () => { + if (stickyTimeoutWaveReservation === reservation) { + stickyTimeoutWaveReservation = null; + } + } + ); + stickyTimeoutWaveLaunchPromise = launchPromise; + try { + await launchPromise; + } finally { + if (stickyTimeoutWaveReservation === reservation) { + stickyTimeoutWaveReservation = null; + } + if (stickyTimeoutWaveClaim === reservation) { + stickyTimeoutWaveClaim = null; + } + if (stickyTimeoutWaveLaunchPromise === launchPromise) { + stickyTimeoutWaveLaunchPromise = null; + } + } }; executeCoordinatorAction = async (action, terminalCancellationKind) => { if (settled || committed) return; - if (action.type === "cancel" || action.type === "launch") { + if ( + action.type === "cancel" || + action.type === "launch" || + (action.type === "terminal_failure" && action.cancelAttemptIds) + ) { const cancelIds = action.type === "cancel" ? action.attemptIds : (action.cancelAttemptIds ?? []); for (const id of cancelIds) { const attempt = attempts.get(id); + const retrySetupReservation = retrySetupReservations.get(id); + const candidateSetupReservation = candidateSetupReservations.get(id); + if (retrySetupReservation) { + cancelSetupReservation(retrySetupReservation, "discovery_sla_timeout"); + } + if (candidateSetupReservation) { + cancelSetupReservation(candidateSetupReservation, "discovery_sla_timeout"); + } // Coordinator marks cancelled attempts non-pending before returning // the action. Restore the transport-facing state long enough for the // exactly-once cancellation/release path to run. if (attempt && !attempt.readerTransferred) attempt.pending = true; if (attempt) cancelAttempt(attempt, "discovery_sla_timeout"); } - if (action.promoteAttemptId) { + if ("promoteAttemptId" in action && action.promoteAttemptId) { const fallback = attempts.get(action.promoteAttemptId); + const retrySetupReservation = retrySetupReservations.get(action.promoteAttemptId); if (fallback) { fallback.kind = "fallback"; discoveryMetrics.fallbackPromoted(fallback.id, fallback.provider.id, fallback.round); } + if (retrySetupReservation) { + const epoch = coordinator.epochs; + retrySetupReservation.requestEpoch = epoch.requestEpoch; + retrySetupReservation.roundEpoch = epoch.roundEpoch; + discoveryMetrics.fallbackPromoted( + action.promoteAttemptId, + retrySetupReservation.providerId, + coordinator.round + ); + } } // A final-round fallback may still be waiting for a protocol-valid // prefix. Keep it alive; markReady will commit it when it becomes safe. @@ -5996,6 +6687,7 @@ export class ProxyForwarder { if (settled || committed) return; if (stickyTimer) clearTimeout(stickyTimer); coordinator.cancelRequest(); + cancelSetupReservations("client_abort"); void settleFailure(new ProxyError("Request aborted by client", 499, undefined, true), { preserveBinding: true, cancellationKind: "client_abort", @@ -6041,25 +6733,43 @@ export class ProxyForwarder { const sticky = Array.from(attempts.values()).find( (attempt) => attempt.pending && attempt.provider.id === initialProvider.id ); - if (sticky) { - if (!coordinator.demoteToFallback(sticky.id)) return; + const stickyRetryReservation = Array.from(retrySetupReservations.values()).find( + (reservation) => reservation.providerId === initialProvider.id + ); + const stickyAttempt = + sticky ?? + (stickyRetryReservation + ? (attempts.get(stickyRetryReservation.placeholderAttemptId) ?? null) + : null); + if (stickyAttempt || stickyRetryReservation) { + const stickyAttemptId = + stickyRetryReservation?.placeholderAttemptId ?? stickyAttempt?.id; + if (!stickyAttemptId || !coordinator.demoteToFallback(stickyAttemptId)) return; stickyProbeActive = false; coordinator.startDiscoveryAfterSticky(); - sticky.kind = "fallback"; - discoveryMetrics.fallbackPromoted(sticky.id, sticky.provider.id, 0); + if (stickyAttempt) stickyAttempt.kind = "fallback"; + if (stickyRetryReservation) { + const epoch = coordinator.epochs; + stickyRetryReservation.requestEpoch = epoch.requestEpoch; + stickyRetryReservation.roundEpoch = epoch.roundEpoch; + } + discoveryMetrics.fallbackPromoted(stickyAttemptId, initialProvider.id, 0); fallbackPromotionBlocked = true; - stickyTimeoutWaveReservation = { fallbackAttemptId: sticky.id }; + stickyTimeoutWaveReservation = { + fallbackAttemptId: stickyAttemptId, + slots: Math.max(0, concurrency - 1), + }; if (bindingSnapshot && bindingSnapshot.providerId === initialProvider.id) { void ensureStickyTimeoutCooldown( Math.ceil((settings.stickyTimeoutCooldownMs ?? 300_000) / 1000) ).finally(() => { - void launchReservedStickyTimeoutWave(Math.max(0, concurrency - 1)).catch( - (error) => logger.warn("[Discovery] Sticky round launch failed", { error }) + void launchReservedStickyTimeoutWave().catch((error) => + logger.warn("[Discovery] Sticky round launch failed", { error }) ); }); return; } - void launchReservedStickyTimeoutWave(Math.max(0, concurrency - 1)).catch((error) => + void launchReservedStickyTimeoutWave().catch((error) => logger.warn("[Discovery] Sticky round launch failed", { error }) ); } diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 45807eed1..64bbfa2b9 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -56,6 +56,7 @@ import { createDemandDrivenResponsePump, type DemandDrivenResponsePump, } from "./demand-driven-response-pump"; +import { isDiscoveryProtocolErrorPayload } from "./discovery-validity"; import { isClientAbortError, isTransportError } from "./errors"; import type { ProxySession } from "./session"; import { @@ -1283,9 +1284,9 @@ function hasOpenAIChatCompletionMarker(data: unknown): boolean { ); } -function hasGeminiCompletionMarker(data: unknown, format: ProxySession["originalFormat"]): boolean { +function hasGeminiCompletionMarker(data: unknown): boolean { if (!isRecord(data)) return false; - const payload = format === "gemini-cli" && isRecord(data.response) ? data.response : data; + const payload = isRecord(data.response) ? data.response : data; if (!Array.isArray(payload.candidates)) return false; return payload.candidates.some( (candidate) => @@ -1307,15 +1308,21 @@ function hasStreamCompletionMarker(text: string, format: ProxySession["originalF switch (format) { case "response": + if (events.some((event) => isDiscoveryProtocolErrorPayload(event.data))) return false; + return events.some((event) => { + if (!isRecord(event.data)) return false; + const markerType = event.data.type; + if (markerType !== "response.completed" && markerType !== "response.done") return false; + if (event.event !== "message" && event.event !== markerType) return false; + return markerType === "response.done" || isRecord(event.data.response); + }); + case "claude": return events.some( (event) => - event.event === "response.completed" && + (event.event === "message_stop" || event.event === "message") && isRecord(event.data) && - event.data.type === "response.completed" && - isRecord(event.data.response) + event.data.type === "message_stop" ); - case "claude": - return events.some((event) => isRecord(event.data) && event.data.type === "message_stop"); case "openai": return events.some( (event) => @@ -1326,7 +1333,7 @@ function hasStreamCompletionMarker(text: string, format: ProxySession["originalF case "gemini": case "gemini-cli": return events.some( - (event) => event.event === "message" && hasGeminiCompletionMarker(event.data, format) + (event) => event.event === "message" && hasGeminiCompletionMarker(event.data) ); } } diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a119800e7..cd9968206 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -850,6 +850,15 @@ export const systemSettings = pgTable('system_settings', { // 关闭:竞速输家直接取消连接,不计费(旧行为) billHedgeLosers: boolean('bill_hedge_losers').notNull().default(true), + // Bounded streaming Discovery (disabled by default until explicitly enabled). + discoveryEnabled: boolean('discovery_enabled').notNull().default(false), + discoveryConcurrency: integer('discovery_concurrency').notNull().default(2), + maxDiscoveryRounds: integer('max_discovery_rounds').notNull().default(2), + discoverySlaMs: integer('discovery_sla_ms').notNull().default(10000), + stickySlaMs: integer('sticky_sla_ms').notNull().default(20000), + racingTotalTimeoutMs: integer('racing_total_timeout_ms').notNull().default(60000), + stickyTimeoutCooldownMs: integer('sticky_timeout_cooldown_ms').notNull().default(300000), + // 系统时区配置 (IANA timezone identifier) // 用于统一后端时间边界计算和前端日期/时间显示 // null 表示使用环境变量 TZ 或默认 UTC diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 8c5d007ca..4af7d75bd 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -11885,6 +11885,20 @@ export interface operations { billNonSuccessfulRequests: boolean; /** @description Whether streaming-hedge (provider racing) losers are kept alive, drained, and billed (their cost accumulates into the request total). */ billHedgeLosers: boolean; + /** @description Whether bounded streaming Discovery is enabled. */ + discoveryEnabled: boolean; + /** @description Maximum number of normal Discovery attempts in the initial batch. */ + discoveryConcurrency: number; + /** @description Maximum number of Discovery rounds. */ + maxDiscoveryRounds: number; + /** @description First-byte Discovery SLA in milliseconds. */ + discoverySlaMs: number; + /** @description Sticky probe SLA in milliseconds. */ + stickySlaMs: number; + /** @description Total pre-winner Discovery deadline in milliseconds. */ + racingTotalTimeoutMs: number; + /** @description Sticky timeout cooldown in milliseconds. */ + stickyTimeoutCooldownMs: number; /** @description Configured system timezone, or null for default. */ timezone: string | null; /** @description Whether usage-log cleanup is enabled. */ @@ -12147,6 +12161,20 @@ export interface operations { billNonSuccessfulRequests?: boolean; /** @description Whether streaming-hedge (provider racing) losers are kept alive, drained, and billed (their cost accumulates into the request total). */ billHedgeLosers?: boolean; + /** @description Whether bounded streaming Discovery is enabled. */ + discoveryEnabled?: boolean; + /** @description Maximum number of normal Discovery attempts in the initial batch. */ + discoveryConcurrency?: number; + /** @description Maximum number of Discovery rounds. */ + maxDiscoveryRounds?: number; + /** @description First-byte Discovery SLA in milliseconds. */ + discoverySlaMs?: number; + /** @description Sticky probe SLA in milliseconds. */ + stickySlaMs?: number; + /** @description Total pre-winner Discovery deadline in milliseconds. */ + racingTotalTimeoutMs?: number; + /** @description Sticky timeout cooldown in milliseconds. */ + stickyTimeoutCooldownMs?: number; /** @description System timezone, or null to use default. */ timezone?: string | null; /** @description Whether usage-log cleanup is enabled. */ @@ -12282,6 +12310,20 @@ export interface operations { billNonSuccessfulRequests: boolean; /** @description Whether streaming-hedge (provider racing) losers are kept alive, drained, and billed (their cost accumulates into the request total). */ billHedgeLosers: boolean; + /** @description Whether bounded streaming Discovery is enabled. */ + discoveryEnabled: boolean; + /** @description Maximum number of normal Discovery attempts in the initial batch. */ + discoveryConcurrency: number; + /** @description Maximum number of Discovery rounds. */ + maxDiscoveryRounds: number; + /** @description First-byte Discovery SLA in milliseconds. */ + discoverySlaMs: number; + /** @description Sticky probe SLA in milliseconds. */ + stickySlaMs: number; + /** @description Total pre-winner Discovery deadline in milliseconds. */ + racingTotalTimeoutMs: number; + /** @description Sticky timeout cooldown in milliseconds. */ + stickyTimeoutCooldownMs: number; /** @description Configured system timezone, or null for default. */ timezone: string | null; /** @description Whether usage-log cleanup is enabled. */ diff --git a/src/lib/api/v1/_shared/error-envelope.ts b/src/lib/api/v1/_shared/error-envelope.ts index 547b98a00..06ccd6e62 100644 --- a/src/lib/api/v1/_shared/error-envelope.ts +++ b/src/lib/api/v1/_shared/error-envelope.ts @@ -69,13 +69,13 @@ export function problem(options: CreateProblemOptions): Response { return createProblemResponse(options); } -export function fromZodError(error: ZodError, instance: string): Response { +export function fromZodError(error: ZodError, instance: string, errorCode?: string): Response { return createProblemResponse({ status: 400, instance, title: "Validation failed", detail: "One or more fields are invalid.", - errorCode: "request.validation_failed", + errorCode: errorCode ?? "request.validation_failed", invalidParams: error.issues.map((issue) => ({ path: normalizeZodPath(issue.path), code: issue.code, diff --git a/src/lib/api/v1/_shared/request-body.ts b/src/lib/api/v1/_shared/request-body.ts index 7a43163bf..60094b359 100644 --- a/src/lib/api/v1/_shared/request-body.ts +++ b/src/lib/api/v1/_shared/request-body.ts @@ -7,6 +7,10 @@ type JsonBodySchema = { safeParse: (value: unknown) => { success: true; data: T } | { success: false; error: z.ZodError }; }; +type ParseJsonBodyOptions = { + validationErrorCode?: (error: z.ZodError) => string | undefined; +}; + type HonoJsonRequest = { req: { raw: Request; @@ -71,7 +75,8 @@ export async function parseJsonBody( export async function parseHonoJsonBody( c: HonoJsonRequest, - schema: JsonBodySchema + schema: JsonBodySchema, + options?: ParseJsonBodyOptions ): Promise> { const contentType = c.req.header("content-type") ?? @@ -113,7 +118,7 @@ export async function parseHonoJsonBody( instance: new URL(c.req.url).pathname, title: "Validation failed", detail: "One or more fields are invalid.", - errorCode: "request.validation_failed", + errorCode: options?.validationErrorCode?.(parsed.error) ?? "request.validation_failed", invalidParams: parsed.error.issues.map((issue) => ({ path: normalizeZodPath(issue.path), code: issue.code, diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index 204c2db66..2fb19b32c 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -1,5 +1,9 @@ import { z } from "@hono/zod-openapi"; import { CURRENCY_CONFIG } from "@/lib/utils/currency"; +import { + DISCOVERY_FIELD_LIMITS, + DISCOVERY_SETTINGS_INVALID_ERROR_CODE, +} from "@/lib/validation/discovery-settings"; import { IsoDateTimeStringSchema } from "./_common"; const currencyValues = Object.keys(CURRENCY_CONFIG) as [ @@ -98,6 +102,43 @@ export const SystemSettingsSchema = z .describe( "Whether streaming-hedge (provider racing) losers are kept alive, drained, and billed (their cost accumulates into the request total)." ), + discoveryEnabled: z.boolean().describe("Whether bounded streaming Discovery is enabled."), + discoveryConcurrency: z + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.discoveryConcurrency[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.discoveryConcurrency[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .describe("Maximum number of normal Discovery attempts in the initial batch."), + maxDiscoveryRounds: z + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.maxDiscoveryRounds[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.maxDiscoveryRounds[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .describe("Maximum number of Discovery rounds."), + discoverySlaMs: z + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.discoverySlaMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.discoverySlaMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .describe("First-byte Discovery SLA in milliseconds."), + stickySlaMs: z + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.stickySlaMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.stickySlaMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .describe("Sticky probe SLA in milliseconds."), + racingTotalTimeoutMs: z + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.racingTotalTimeoutMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.racingTotalTimeoutMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .describe("Total pre-winner Discovery deadline in milliseconds."), + stickyTimeoutCooldownMs: z + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.stickyTimeoutCooldownMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.stickyTimeoutCooldownMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .describe("Sticky timeout cooldown in milliseconds."), timezone: TimeZoneSchema.nullable().describe( "Configured system timezone, or null for default." ), diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 0ac909bef..4707b2b76 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -61,7 +61,7 @@ export function getCachedSystemSettingsOnlyCache(): SystemSettings | null { } /** Default settings used when cache fetch fails */ -const DEFAULT_SETTINGS: Pick< +export const DEFAULT_SETTINGS: Pick< SystemSettings, | "enableHttp2" | "enableOpenaiResponsesWebsocket" @@ -83,6 +83,13 @@ const DEFAULT_SETTINGS: Pick< | "passThroughUpstreamErrorMessage" | "publicStatusWindowHours" | "publicStatusAggregationIntervalMinutes" + | "discoveryEnabled" + | "discoveryConcurrency" + | "maxDiscoveryRounds" + | "discoverySlaMs" + | "stickySlaMs" + | "racingTotalTimeoutMs" + | "stickyTimeoutCooldownMs" > = { enableHttp2: false, enableOpenaiResponsesWebsocket: true, @@ -113,6 +120,13 @@ const DEFAULT_SETTINGS: Pick< }, publicStatusWindowHours: 24, publicStatusAggregationIntervalMinutes: 5, + discoveryEnabled: false, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + racingTotalTimeoutMs: 60_000, + stickyTimeoutCooldownMs: 300_000, }; /** @@ -195,13 +209,13 @@ export async function getCachedSystemSettings(): Promise { publicStatusWindowHours: DEFAULT_SETTINGS.publicStatusWindowHours, publicStatusAggregationIntervalMinutes: DEFAULT_SETTINGS.publicStatusAggregationIntervalMinutes, - discoveryEnabled: false, - discoveryConcurrency: 2, - maxDiscoveryRounds: 2, - discoverySlaMs: 10_000, - stickySlaMs: 20_000, - racingTotalTimeoutMs: 60_000, - stickyTimeoutCooldownMs: 300_000, + discoveryEnabled: DEFAULT_SETTINGS.discoveryEnabled, + discoveryConcurrency: DEFAULT_SETTINGS.discoveryConcurrency, + maxDiscoveryRounds: DEFAULT_SETTINGS.maxDiscoveryRounds, + discoverySlaMs: DEFAULT_SETTINGS.discoverySlaMs, + stickySlaMs: DEFAULT_SETTINGS.stickySlaMs, + racingTotalTimeoutMs: DEFAULT_SETTINGS.racingTotalTimeoutMs, + stickyTimeoutCooldownMs: DEFAULT_SETTINGS.stickyTimeoutCooldownMs, quotaDbRefreshIntervalSeconds: 10, quotaLeasePercent5h: 0.05, quotaLeasePercentDaily: 0.05, diff --git a/src/lib/validation/discovery-settings.ts b/src/lib/validation/discovery-settings.ts new file mode 100644 index 000000000..319c6f382 --- /dev/null +++ b/src/lib/validation/discovery-settings.ts @@ -0,0 +1,28 @@ +export const DISCOVERY_SETTINGS_INVALID_ERROR_CODE = "DISCOVERY_SETTINGS_INVALID"; +export const DISCOVERY_WINDOW_INVALID_ERROR_CODE = "DISCOVERY_WINDOW_INVALID"; + +export const DISCOVERY_FIELD_LIMITS = { + discoveryConcurrency: [2, 32], + maxDiscoveryRounds: [1, 32], + discoverySlaMs: [1, 300_000], + stickySlaMs: [1, 600_000], + racingTotalTimeoutMs: [1, 3_600_000], + stickyTimeoutCooldownMs: [1, 86_400_000], +} as const; + +export type DiscoverySettingField = keyof typeof DISCOVERY_FIELD_LIMITS; + +export function isDiscoverySettingField(value: unknown): value is DiscoverySettingField { + return typeof value === "string" && value in DISCOVERY_FIELD_LIMITS; +} + +export function getDiscoveryValidationErrorCode( + issues: ReadonlyArray<{ message: string; path: readonly PropertyKey[] }> +): string | undefined { + if (issues.some((issue) => issue.message === DISCOVERY_WINDOW_INVALID_ERROR_CODE)) { + return DISCOVERY_WINDOW_INVALID_ERROR_CODE; + } + return issues.some((issue) => isDiscoverySettingField(issue.path[0])) + ? DISCOVERY_SETTINGS_INVALID_ERROR_CODE + : undefined; +} diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 9864435ea..0b5ccf793 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -16,6 +16,16 @@ import { } from "@/lib/public-status/constants"; import { CURRENCY_CONFIG } from "@/lib/utils/currency"; import { isValidIANATimezone } from "@/lib/utils/timezone"; +import { + DISCOVERY_FIELD_LIMITS, + DISCOVERY_SETTINGS_INVALID_ERROR_CODE, + DISCOVERY_WINDOW_INVALID_ERROR_CODE, +} from "./discovery-settings"; + +export { + DISCOVERY_SETTINGS_INVALID_ERROR_CODE, + DISCOVERY_WINDOW_INVALID_ERROR_CODE, +} from "./discovery-settings"; const CACHE_TTL_PREFERENCE = z.enum(["inherit", "5m", "1h"]); const CONTEXT_1M_PREFERENCE = z.enum(["inherit", "force_enable", "disabled"]); @@ -947,205 +957,270 @@ export const UpdateProviderSchema = z * 系统设置更新数据验证schema * 注意:所有字段均为可选,支持部分更新 */ -export const UpdateSystemSettingsSchema = z.object({ - siteTitle: z.string().min(1, "站点标题不能为空").max(128, "站点标题不能超过128个字符").optional(), - allowGlobalUsageView: z.boolean().optional(), - currencyDisplay: z - .enum( - Object.keys(CURRENCY_CONFIG) as [ - keyof typeof CURRENCY_CONFIG, - ...Array, - ], - { message: "不支持的货币类型" } - ) - .optional(), - // 计费模型来源配置(可选) - billingModelSource: z - .enum(["original", "redirected"], { message: "不支持的计费模型来源" }) - .optional(), - codexPriorityBillingSource: z - .enum(["requested", "actual"], { message: "不支持的 Codex Priority 计费来源" }) - .optional(), - // 系统时区配置(可选) - // 必须是有效的 IANA 时区标识符(如 "Asia/Shanghai", "America/New_York") - timezone: z - .string() - .refine((val) => isValidIANATimezone(val), { - message: "无效的时区标识符,请使用 IANA 时区格式(如 Asia/Shanghai)", - }) - .nullable() - .optional(), - // 日志清理配置(可选) - enableAutoCleanup: z.boolean().optional(), - cleanupRetentionDays: z.coerce - .number() - .int("保留天数必须是整数") - .min(1, "保留天数不能少于1天") - .max(365, "保留天数不能超过365天") - .optional(), - cleanupSchedule: z.string().min(1, "执行时间不能为空").optional(), - cleanupBatchSize: z.coerce - .number() - .int("批量大小必须是整数") - .min(1000, "批量大小不能少于1000") - .max(100000, "批量大小不能超过100000") - .optional(), - // 客户端版本检查配置(可选) - enableClientVersionCheck: z.boolean().optional(), - // 供应商不可用时是否返回详细错误信息(可选) - verboseProviderError: z.boolean().optional(), - // 标准代理错误响应是否透传安全脱敏后的上游错误 message(可选) - passThroughUpstreamErrorMessage: z.boolean().optional(), - // 启用 HTTP/2 连接供应商(可选) - enableHttp2: z.boolean().optional(), - // 非成功请求按 token 用量计费(可选;默认关闭) - billNonSuccessfulRequests: z.boolean().optional(), - // 供应商竞速输家计费(可选;默认开启) - billHedgeLosers: z.boolean().optional(), - // 启用 OpenAI Responses WebSocket 支持(可选,仅 Codex 类型供应商生效) - enableOpenaiResponsesWebsocket: z.boolean().optional(), - // 高并发模式(可选) - enableHighConcurrencyMode: z.boolean().optional(), - // 可选拦截 Anthropic Warmup 请求(可选) - interceptAnthropicWarmupRequests: z.boolean().optional(), - // thinking signature 整流器(可选) - enableThinkingSignatureRectifier: z.boolean().optional(), - // thinking budget 整流器(可选) - enableThinkingBudgetRectifier: z.boolean().optional(), - // thinking effort 冲突整流器(可选) - enableThinkingEffortConflictRectifier: z.boolean().optional(), - // Gemini function id 整流器(可选) - enableGeminiFunctionIdRectifier: z.boolean().optional(), - // billing header 整流器(可选) - enableBillingHeaderRectifier: z.boolean().optional(), - // Response API input 整流器(可选) - enableResponseInputRectifier: z.boolean().optional(), - // 非对话端点跨供应商 fallback(可选) - allowNonConversationEndpointProviderFallback: z.boolean().optional(), - // Fake 流式输出白名单(可选)。空数组表示显式禁用;缺省 → 使用默认四个图像生成模型。 - fakeStreamingWhitelist: z - .array( - z.object({ - model: z - .string() - .min(1, "model 不能为空") - .max(200, "model 不能超过 200 个字符") - .transform((value) => value.trim()) - .refine((value) => value.length > 0, { message: "model 不能为空" }), - groupTags: z - .array( - z - .string() - .min(1) - .transform((value) => value.trim()) - .refine((value) => value.length > 0, { message: "groupTag 不能为空" }) - ) - .default([]) - .transform((tags) => Array.from(new Set(tags))), +export const UpdateSystemSettingsSchema = z + .object({ + siteTitle: z + .string() + .min(1, "站点标题不能为空") + .max(128, "站点标题不能超过128个字符") + .optional(), + allowGlobalUsageView: z.boolean().optional(), + currencyDisplay: z + .enum( + Object.keys(CURRENCY_CONFIG) as [ + keyof typeof CURRENCY_CONFIG, + ...Array, + ], + { message: "不支持的货币类型" } + ) + .optional(), + // 计费模型来源配置(可选) + billingModelSource: z + .enum(["original", "redirected"], { message: "不支持的计费模型来源" }) + .optional(), + codexPriorityBillingSource: z + .enum(["requested", "actual"], { message: "不支持的 Codex Priority 计费来源" }) + .optional(), + // 系统时区配置(可选) + // 必须是有效的 IANA 时区标识符(如 "Asia/Shanghai", "America/New_York") + timezone: z + .string() + .refine((val) => isValidIANATimezone(val), { + message: "无效的时区标识符,请使用 IANA 时区格式(如 Asia/Shanghai)", }) - ) - .superRefine((entries, ctx) => { - const seen = new Set(); - for (let index = 0; index < entries.length; index += 1) { - const model = entries[index].model; - if (seen.has(model)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `fakeStreamingWhitelist 模型重复: ${model}`, - path: [index, "model"], - }); + .nullable() + .optional(), + // 日志清理配置(可选) + enableAutoCleanup: z.boolean().optional(), + cleanupRetentionDays: z.coerce + .number() + .int("保留天数必须是整数") + .min(1, "保留天数不能少于1天") + .max(365, "保留天数不能超过365天") + .optional(), + cleanupSchedule: z.string().min(1, "执行时间不能为空").optional(), + cleanupBatchSize: z.coerce + .number() + .int("批量大小必须是整数") + .min(1000, "批量大小不能少于1000") + .max(100000, "批量大小不能超过100000") + .optional(), + // 客户端版本检查配置(可选) + enableClientVersionCheck: z.boolean().optional(), + // 供应商不可用时是否返回详细错误信息(可选) + verboseProviderError: z.boolean().optional(), + // 标准代理错误响应是否透传安全脱敏后的上游错误 message(可选) + passThroughUpstreamErrorMessage: z.boolean().optional(), + // 启用 HTTP/2 连接供应商(可选) + enableHttp2: z.boolean().optional(), + // 非成功请求按 token 用量计费(可选;默认关闭) + billNonSuccessfulRequests: z.boolean().optional(), + // 供应商竞速输家计费(可选;默认开启) + billHedgeLosers: z.boolean().optional(), + // Bounded streaming Discovery(默认关闭;启用前需满足总窗口约束) + discoveryEnabled: z.boolean().optional(), + discoveryConcurrency: z.coerce + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.discoveryConcurrency[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.discoveryConcurrency[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .optional(), + maxDiscoveryRounds: z.coerce + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.maxDiscoveryRounds[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.maxDiscoveryRounds[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .optional(), + discoverySlaMs: z.coerce + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.discoverySlaMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.discoverySlaMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .optional(), + stickySlaMs: z.coerce + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.stickySlaMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.stickySlaMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .optional(), + racingTotalTimeoutMs: z.coerce + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.racingTotalTimeoutMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.racingTotalTimeoutMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .optional(), + stickyTimeoutCooldownMs: z.coerce + .number() + .int(DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .min(DISCOVERY_FIELD_LIMITS.stickyTimeoutCooldownMs[0], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .max(DISCOVERY_FIELD_LIMITS.stickyTimeoutCooldownMs[1], DISCOVERY_SETTINGS_INVALID_ERROR_CODE) + .optional(), + // 启用 OpenAI Responses WebSocket 支持(可选,仅 Codex 类型供应商生效) + enableOpenaiResponsesWebsocket: z.boolean().optional(), + // 高并发模式(可选) + enableHighConcurrencyMode: z.boolean().optional(), + // 可选拦截 Anthropic Warmup 请求(可选) + interceptAnthropicWarmupRequests: z.boolean().optional(), + // thinking signature 整流器(可选) + enableThinkingSignatureRectifier: z.boolean().optional(), + // thinking budget 整流器(可选) + enableThinkingBudgetRectifier: z.boolean().optional(), + // thinking effort 冲突整流器(可选) + enableThinkingEffortConflictRectifier: z.boolean().optional(), + // Gemini function id 整流器(可选) + enableGeminiFunctionIdRectifier: z.boolean().optional(), + // billing header 整流器(可选) + enableBillingHeaderRectifier: z.boolean().optional(), + // Response API input 整流器(可选) + enableResponseInputRectifier: z.boolean().optional(), + // 非对话端点跨供应商 fallback(可选) + allowNonConversationEndpointProviderFallback: z.boolean().optional(), + // Fake 流式输出白名单(可选)。空数组表示显式禁用;缺省 → 使用默认四个图像生成模型。 + fakeStreamingWhitelist: z + .array( + z.object({ + model: z + .string() + .min(1, "model 不能为空") + .max(200, "model 不能超过 200 个字符") + .transform((value) => value.trim()) + .refine((value) => value.length > 0, { message: "model 不能为空" }), + groupTags: z + .array( + z + .string() + .min(1) + .transform((value) => value.trim()) + .refine((value) => value.length > 0, { message: "groupTag 不能为空" }) + ) + .default([]) + .transform((tags) => Array.from(new Set(tags))), + }) + ) + .superRefine((entries, ctx) => { + const seen = new Set(); + for (let index = 0; index < entries.length; index += 1) { + const model = entries[index].model; + if (seen.has(model)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `fakeStreamingWhitelist 模型重复: ${model}`, + path: [index, "model"], + }); + } + seen.add(model); } - seen.add(model); - } - }) - .optional(), - // Codex Session ID 补全(可选) - enableCodexSessionIdCompletion: z.boolean().optional(), - // Claude metadata.user_id 注入(可选) - enableClaudeMetadataUserIdInjection: z.boolean().optional(), - // 响应整流(可选) - enableResponseFixer: z.boolean().optional(), - responseFixerConfig: z - .object({ - fixTruncatedJson: z.boolean().optional(), - fixSseFormat: z.boolean().optional(), - fixEncoding: z.boolean().optional(), - maxJsonDepth: z.coerce.number().int("maxJsonDepth 必须是整数").min(1).max(2000).optional(), - maxFixSize: z.coerce - .number() - .int("maxFixSize 必须是整数") - .min(1024) - .max(10 * 1024 * 1024) - .optional(), - }) - .partial() - .optional(), + }) + .optional(), + // Codex Session ID 补全(可选) + enableCodexSessionIdCompletion: z.boolean().optional(), + // Claude metadata.user_id 注入(可选) + enableClaudeMetadataUserIdInjection: z.boolean().optional(), + // 响应整流(可选) + enableResponseFixer: z.boolean().optional(), + responseFixerConfig: z + .object({ + fixTruncatedJson: z.boolean().optional(), + fixSseFormat: z.boolean().optional(), + fixEncoding: z.boolean().optional(), + maxJsonDepth: z.coerce.number().int("maxJsonDepth 必须是整数").min(1).max(2000).optional(), + maxFixSize: z.coerce + .number() + .int("maxFixSize 必须是整数") + .min(1024) + .max(10 * 1024 * 1024) + .optional(), + }) + .partial() + .optional(), - // Quota lease settings - quotaDbRefreshIntervalSeconds: z.coerce - .number() - .int("DB refresh interval must be an integer") - .min(1, "DB refresh interval cannot be less than 1 second") - .max(300, "DB refresh interval cannot exceed 300 seconds") - .optional(), - quotaLeasePercent5h: z.coerce - .number() - .min(0, "Lease percent cannot be negative") - .max(1, "Lease percent cannot exceed 1") - .optional(), - quotaLeasePercentDaily: z.coerce - .number() - .min(0, "Lease percent cannot be negative") - .max(1, "Lease percent cannot exceed 1") - .optional(), - quotaLeasePercentWeekly: z.coerce - .number() - .min(0, "Lease percent cannot be negative") - .max(1, "Lease percent cannot exceed 1") - .optional(), - quotaLeasePercentMonthly: z.coerce - .number() - .min(0, "Lease percent cannot be negative") - .max(1, "Lease percent cannot exceed 1") - .optional(), - quotaLeaseCapUsd: z.coerce.number().min(0, "Lease cap cannot be negative").nullable().optional(), - publicStatusWindowHours: z.coerce - .number() - .int("PUBLIC_STATUS_WINDOW_INVALID_INT") - .min(1, "PUBLIC_STATUS_WINDOW_TOO_SMALL") - .max(MAX_PUBLIC_STATUS_RANGE_HOURS, "PUBLIC_STATUS_WINDOW_TOO_LARGE") - .optional(), - publicStatusAggregationIntervalMinutes: z.coerce - .number() - .int("PUBLIC_STATUS_INTERVAL_INVALID_INT") - .refine( - (value) => - PUBLIC_STATUS_INTERVAL_OPTIONS.includes( - value as (typeof PUBLIC_STATUS_INTERVAL_OPTIONS)[number] - ), - { - message: "PUBLIC_STATUS_INTERVAL_INVALID", - } - ) - .optional(), + // Quota lease settings + quotaDbRefreshIntervalSeconds: z.coerce + .number() + .int("DB refresh interval must be an integer") + .min(1, "DB refresh interval cannot be less than 1 second") + .max(300, "DB refresh interval cannot exceed 300 seconds") + .optional(), + quotaLeasePercent5h: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeasePercentDaily: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeasePercentWeekly: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeasePercentMonthly: z.coerce + .number() + .min(0, "Lease percent cannot be negative") + .max(1, "Lease percent cannot exceed 1") + .optional(), + quotaLeaseCapUsd: z.coerce + .number() + .min(0, "Lease cap cannot be negative") + .nullable() + .optional(), + publicStatusWindowHours: z.coerce + .number() + .int("PUBLIC_STATUS_WINDOW_INVALID_INT") + .min(1, "PUBLIC_STATUS_WINDOW_TOO_SMALL") + .max(MAX_PUBLIC_STATUS_RANGE_HOURS, "PUBLIC_STATUS_WINDOW_TOO_LARGE") + .optional(), + publicStatusAggregationIntervalMinutes: z.coerce + .number() + .int("PUBLIC_STATUS_INTERVAL_INVALID_INT") + .refine( + (value) => + PUBLIC_STATUS_INTERVAL_OPTIONS.includes( + value as (typeof PUBLIC_STATUS_INTERVAL_OPTIONS)[number] + ), + { + message: "PUBLIC_STATUS_INTERVAL_INVALID", + } + ) + .optional(), - // 客户端 IP 提取链(可选;null 表示使用内置默认) - ipExtractionConfig: z - .union([ - z.null(), - z.object({ - headers: z.array( - z.object({ - name: z.string(), - pick: XFF_PICK_SCHEMA.optional(), - }) - ), - }), - ]) - .optional(), - // 是否启用 IP 归属地查询(可选) - ipGeoLookupEnabled: z.boolean().optional(), -}); + // 客户端 IP 提取链(可选;null 表示使用内置默认) + ipExtractionConfig: z + .union([ + z.null(), + z.object({ + headers: z.array( + z.object({ + name: z.string(), + pick: XFF_PICK_SCHEMA.optional(), + }) + ), + }), + ]) + .optional(), + // 是否启用 IP 归属地查询(可选) + ipGeoLookupEnabled: z.boolean().optional(), + }) + .superRefine((data, ctx) => { + const values = [ + data.racingTotalTimeoutMs, + data.stickySlaMs, + data.maxDiscoveryRounds, + data.discoverySlaMs, + ]; + if (values.every((value) => value !== undefined)) { + const requiredWindow = data.stickySlaMs! + data.maxDiscoveryRounds! * data.discoverySlaMs!; + if (data.racingTotalTimeoutMs! < requiredWindow) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["racingTotalTimeoutMs"], + message: DISCOVERY_WINDOW_INVALID_ERROR_CODE, + }); + } + } + }); // 导出类型推断 diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 51fc05202..568a50ba7 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -298,6 +298,13 @@ export function toSystemSettings(dbSettings: any): SystemSettings { quotaLeaseCapUsd: dbSettings?.quotaLeaseCapUsd ? parseFloat(dbSettings.quotaLeaseCapUsd) : null, publicStatusWindowHours: dbSettings?.publicStatusWindowHours ?? 24, publicStatusAggregationIntervalMinutes: dbSettings?.publicStatusAggregationIntervalMinutes ?? 5, + discoveryEnabled: dbSettings?.discoveryEnabled ?? false, + discoveryConcurrency: dbSettings?.discoveryConcurrency ?? 2, + maxDiscoveryRounds: dbSettings?.maxDiscoveryRounds ?? 2, + discoverySlaMs: dbSettings?.discoverySlaMs ?? 10_000, + stickySlaMs: dbSettings?.stickySlaMs ?? 20_000, + racingTotalTimeoutMs: dbSettings?.racingTotalTimeoutMs ?? 60_000, + stickyTimeoutCooldownMs: dbSettings?.stickyTimeoutCooldownMs ?? 300_000, ipExtractionConfig: dbSettings?.ipExtractionConfig ?? null, ipGeoLookupEnabled: dbSettings?.ipGeoLookupEnabled ?? true, createdAt: dbSettings?.createdAt ? new Date(dbSettings.createdAt) : new Date(), diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 20a53bc3b..1e6ba0943 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -191,6 +191,13 @@ function createFallbackSettings(): SystemSettings { quotaLeaseCapUsd: null, publicStatusWindowHours: 24, publicStatusAggregationIntervalMinutes: 5, + discoveryEnabled: false, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + racingTotalTimeoutMs: 60_000, + stickyTimeoutCooldownMs: 300_000, ipExtractionConfig: null, ipGeoLookupEnabled: true, createdAt: now, @@ -247,6 +254,13 @@ const BASE_SETTINGS_COLUMNS: SettingsSelection = { quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, publicStatusWindowHours: systemSettings.publicStatusWindowHours, publicStatusAggregationIntervalMinutes: systemSettings.publicStatusAggregationIntervalMinutes, + discoveryEnabled: systemSettings.discoveryEnabled, + discoveryConcurrency: systemSettings.discoveryConcurrency, + maxDiscoveryRounds: systemSettings.maxDiscoveryRounds, + discoverySlaMs: systemSettings.discoverySlaMs, + stickySlaMs: systemSettings.stickySlaMs, + racingTotalTimeoutMs: systemSettings.racingTotalTimeoutMs, + stickyTimeoutCooldownMs: systemSettings.stickyTimeoutCooldownMs, createdAt: systemSettings.createdAt, updatedAt: systemSettings.updatedAt, passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, @@ -264,6 +278,48 @@ const RECENT_COLUMN_LADDER: ReadonlyArray<{ // 本层更新失败(仍有列缺失)时记录的告警 updateWarn: string; }> = [ + { + key: "stickyTimeoutCooldownMs", + column: systemSettings.stickyTimeoutCooldownMs, + selectWarn: "system_settings 缺少 stickyTimeoutCooldownMs,回退到上一代字段集。", + updateWarn: "system_settings 缺少 stickyTimeoutCooldownMs,回退到上一代字段集。", + }, + { + key: "racingTotalTimeoutMs", + column: systemSettings.racingTotalTimeoutMs, + selectWarn: "system_settings 缺少 racingTotalTimeoutMs,回退到上一代字段集。", + updateWarn: "system_settings 缺少 racingTotalTimeoutMs,回退到上一代字段集。", + }, + { + key: "stickySlaMs", + column: systemSettings.stickySlaMs, + selectWarn: "system_settings 缺少 stickySlaMs,回退到上一代字段集。", + updateWarn: "system_settings 缺少 stickySlaMs,回退到上一代字段集。", + }, + { + key: "discoverySlaMs", + column: systemSettings.discoverySlaMs, + selectWarn: "system_settings 缺少 discoverySlaMs,回退到上一代字段集。", + updateWarn: "system_settings 缺少 discoverySlaMs,回退到上一代字段集。", + }, + { + key: "maxDiscoveryRounds", + column: systemSettings.maxDiscoveryRounds, + selectWarn: "system_settings 缺少 maxDiscoveryRounds,回退到上一代字段集。", + updateWarn: "system_settings 缺少 maxDiscoveryRounds,回退到上一代字段集。", + }, + { + key: "discoveryConcurrency", + column: systemSettings.discoveryConcurrency, + selectWarn: "system_settings 缺少 discoveryConcurrency,回退到上一代字段集。", + updateWarn: "system_settings 缺少 discoveryConcurrency,回退到上一代字段集。", + }, + { + key: "discoveryEnabled", + column: systemSettings.discoveryEnabled, + selectWarn: "system_settings 缺少 discoveryEnabled,回退到上一代字段集。", + updateWarn: "system_settings 缺少 discoveryEnabled,回退到上一代字段集。", + }, { key: "enableGeminiFunctionIdRectifier", column: systemSettings.enableGeminiFunctionIdRectifier, @@ -620,6 +676,28 @@ export async function updateSystemSettings( updates.billHedgeLosers = payload.billHedgeLosers; } + if (payload.discoveryEnabled !== undefined) { + updates.discoveryEnabled = payload.discoveryEnabled; + } + if (payload.discoveryConcurrency !== undefined) { + updates.discoveryConcurrency = payload.discoveryConcurrency; + } + if (payload.maxDiscoveryRounds !== undefined) { + updates.maxDiscoveryRounds = payload.maxDiscoveryRounds; + } + if (payload.discoverySlaMs !== undefined) { + updates.discoverySlaMs = payload.discoverySlaMs; + } + if (payload.stickySlaMs !== undefined) { + updates.stickySlaMs = payload.stickySlaMs; + } + if (payload.racingTotalTimeoutMs !== undefined) { + updates.racingTotalTimeoutMs = payload.racingTotalTimeoutMs; + } + if (payload.stickyTimeoutCooldownMs !== undefined) { + updates.stickyTimeoutCooldownMs = payload.stickyTimeoutCooldownMs; + } + // 系统时区配置字段(如果提供) if (payload.timezone !== undefined) { updates.timezone = payload.timezone; diff --git a/src/types/system-config.ts b/src/types/system-config.ts index 16ef36a98..dd47b7b06 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -146,14 +146,14 @@ export interface SystemSettings { publicStatusWindowHours: number; publicStatusAggregationIntervalMinutes: number; - /** Bounded streaming Discovery (PR2; persisted/configured in PR3). */ - discoveryEnabled?: boolean; - discoveryConcurrency?: number; - maxDiscoveryRounds?: number; - discoverySlaMs?: number; - stickySlaMs?: number; - racingTotalTimeoutMs?: number; - stickyTimeoutCooldownMs?: number; + /** Bounded streaming Discovery settings. */ + discoveryEnabled: boolean; + discoveryConcurrency: number; + maxDiscoveryRounds: number; + discoverySlaMs: number; + stickySlaMs: number; + racingTotalTimeoutMs: number; + stickyTimeoutCooldownMs: number; createdAt: Date; updatedAt: Date; diff --git a/tests/api/v1/system/system-config.test.ts b/tests/api/v1/system/system-config.test.ts index dfd4b5fc2..306ee3b1a 100644 --- a/tests/api/v1/system/system-config.test.ts +++ b/tests/api/v1/system/system-config.test.ts @@ -176,6 +176,21 @@ describe("v1 system config endpoints", () => { expect(invalidTimezone.json).toMatchObject({ errorCode: "request.validation_failed" }); }); + test("returns a stable error code for out-of-range Discovery settings", async () => { + const invalidDiscovery = await callV1Route({ + method: "PUT", + pathname: "/api/v1/system/settings", + headers: { Authorization: "Bearer admin-token" }, + body: { discoveryConcurrency: 33 }, + }); + + expect(invalidDiscovery.response.status).toBe(400); + expect(invalidDiscovery.json).toMatchObject({ + errorCode: "DISCOVERY_SETTINGS_INVALID", + }); + expect(saveSystemSettingsMock).not.toHaveBeenCalled(); + }); + test("rejects malformed and non-json system settings update bodies", async () => { const handlers = await import("@/app/api/v1/resources/system/handlers"); const malformed = await handlers.updateSystemSettings({ diff --git a/tests/unit/actions/system-config-save.test.ts b/tests/unit/actions/system-config-save.test.ts index a77a8f91f..89c19dd30 100644 --- a/tests/unit/actions/system-config-save.test.ts +++ b/tests/unit/actions/system-config-save.test.ts @@ -152,6 +152,33 @@ describe("saveSystemSettings", () => { ); }); + it("returns a structured error code for invalid Discovery field ranges", async () => { + const result = await saveSystemSettings({ discoveryConcurrency: 33 }); + + expect(result).toMatchObject({ + ok: false, + error: "Discovery settings validation failed.", + errorCode: "DISCOVERY_SETTINGS_INVALID", + }); + expect(updateSystemSettingsMock).not.toHaveBeenCalled(); + }); + + it("preserves the structured Discovery window error code from schema validation", async () => { + const result = await saveSystemSettings({ + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + maxDiscoveryRounds: 2, + racingTotalTimeoutMs: 30_000, + }); + + expect(result).toMatchObject({ + ok: false, + error: "Discovery settings validation failed.", + errorCode: "DISCOVERY_WINDOW_INVALID", + }); + expect(updateSystemSettingsMock).not.toHaveBeenCalled(); + }); + it("should invalidate system settings cache after successful save", async () => { await saveSystemSettings({ siteTitle: "New Title" }); diff --git a/tests/unit/api/admin-system-config-route.test.ts b/tests/unit/api/admin-system-config-route.test.ts new file mode 100644 index 000000000..d6eb23e2f --- /dev/null +++ b/tests/unit/api/admin-system-config-route.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getSystemSettings: vi.fn(), + updateSystemSettings: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/lib/config", () => ({ + invalidateSystemSettingsCache: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/redis", () => ({ + invalidateAllLeaderboardCaches: vi.fn(), + invalidateAllOverviewCaches: vi.fn(), + invalidateAllStatisticsCaches: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, + updateSystemSettings: mocks.updateSystemSettings, +})); + +describe("POST /api/admin/system-config", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue({ user: { id: 1, role: "admin" } }); + mocks.getSystemSettings.mockResolvedValue({ + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + maxDiscoveryRounds: 2, + racingTotalTimeoutMs: 60_000, + }); + }); + + it("returns the stable Discovery window code for an invalid partial update", async () => { + const { POST } = await import("@/app/api/admin/system-config/route"); + const response = await POST( + new Request("http://localhost/api/admin/system-config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ racingTotalTimeoutMs: 39_999 }), + }) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "discoveryWindowInvalid", + errorCode: "DISCOVERY_WINDOW_INVALID", + }); + expect(mocks.updateSystemSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/proxy/discovery-coordinator.test.ts b/tests/unit/proxy/discovery-coordinator.test.ts index 1ce247f52..311a17024 100644 --- a/tests/unit/proxy/discovery-coordinator.test.ts +++ b/tests/unit/proxy/discovery-coordinator.test.ts @@ -18,6 +18,7 @@ describe("DiscoveryCoordinator", () => { coordinator.startStickyProbe(); expect(coordinator.state).toBe("STICKY_PROBING"); expect(coordinator.round).toBe(1); + expect(coordinator.canRefillCurrentRound).toBe(false); coordinator.addAttempt(attempt("sticky", 1)); expect(coordinator.demoteToFallback("sticky")).toBe(true); @@ -25,6 +26,7 @@ describe("DiscoveryCoordinator", () => { expect(coordinator.state).toBe("DISCOVERY_RACING"); expect(coordinator.round).toBe(1); + expect(coordinator.canRefillCurrentRound).toBe(true); }); it("commits the highest priority ready normal attempt", () => { @@ -48,6 +50,57 @@ describe("DiscoveryCoordinator", () => { }); expect(coordinator.snapshot.find((item) => item.id === "a")?.kind).toBe("fallback"); expect(coordinator.snapshot.filter((item) => item.pending)).toHaveLength(1); + expect(coordinator.canRefillCurrentRound).toBe(true); + }); + + it("closes refills after the final round boundary", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 1 }); + coordinator.addAttempt(attempt("a", 1)); + coordinator.addAttempt(attempt("b", 2)); + + expect(coordinator.onRoundBoundary()).toEqual({ + type: "cancel", + attemptIds: ["b"], + promoteAttemptId: "a", + }); + expect(coordinator.canRefillCurrentRound).toBe(false); + }); + + it("counts setup reservations as occupied slots without promoting them to fallback", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 2 }); + coordinator.addAttempt(attempt("transport", 1)); + coordinator.addAttempt({ + ...attempt("setup", 2), + providerId: 0, + priority: Number.POSITIVE_INFINITY, + setupOnly: true, + }); + + expect(coordinator.activeAttempts).toHaveLength(2); + expect(coordinator.onRoundBoundary()).toEqual({ + type: "launch", + slots: 1, + cancelAttemptIds: ["setup"], + promoteAttemptId: "transport", + }); + expect(coordinator.snapshot.find((item) => item.id === "transport")?.kind).toBe("fallback"); + expect(coordinator.snapshot.find((item) => item.id === "setup")?.pending).toBe(false); + }); + + it("terminates the final round instead of promoting a setup-only reservation", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 1, maxRounds: 1 }); + coordinator.addAttempt({ + ...attempt("setup", 1), + providerId: 0, + priority: Number.POSITIVE_INFINITY, + setupOnly: true, + }); + + expect(coordinator.onRoundBoundary()).toEqual({ + type: "terminal_failure", + cancelAttemptIds: ["setup"], + }); + expect(coordinator.state).toBe("TERMINAL_FAILED"); }); it("ignores callbacks from an old request epoch", () => { diff --git a/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index fc76b1b19..b553694dc 100644 --- a/tests/unit/proxy/discovery-validity.test.ts +++ b/tests/unit/proxy/discovery-validity.test.ts @@ -27,6 +27,21 @@ describe("discovery validity", () => { expect(classifyDiscoveryChunk("data: [DONE]\n\n", "openai-chat").terminal).toBe(true); }); + it("accepts Gemini candidates in the supported response wrapper", () => { + expect( + classifyDiscoveryChunk( + '{"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}}', + "gemini" + ) + ).toEqual({ ready: true, terminal: false, error: false }); + }); + + it("keeps wrapped Gemini errors terminal", () => { + expect( + classifyDiscoveryChunk('{"response":{"error":{"message":"upstream failed"}}}', "gemini") + ).toEqual({ ready: false, terminal: true, error: true }); + }); + it("keeps stateless readiness when content and DONE share a chunk", () => { expect( classifyDiscoveryChunk( @@ -44,13 +59,27 @@ describe("discovery validity", () => { ) ).toEqual({ ready: true, terminal: false, error: false }); }); - it("rejects errors even when a later chunk contains content", () => { const parser = new DiscoveryValidityParser("openai-responses"); expect(parser.push('{"type":"response.failed","error":{"message":"no"}}').error).toBe(true); expect(parser.push('{"type":"response.output_text.delta","delta":"late"}').ready).toBe(false); }); + it.each([ + '{"type":"response.error"}', + '{"failed":true}', + '{"type":"response.done","response":{"error":{"message":"no"}}}', + ])("rejects Responses protocol error payload %s", (payload) => { + const parser = new DiscoveryValidityParser("openai-responses"); + + expect(parser.push(payload)).toMatchObject({ ready: false, terminal: true, error: true }); + expect(parser.push('{"type":"response.done"}')).toMatchObject({ + ready: false, + terminal: true, + error: true, + }); + }); + it("does not promote empty tool or content events", () => { expect( classifyDiscoveryChunk( diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts index bd2d4ee90..6b6d0226c 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -2555,8 +2555,9 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { expect(mocks.getCachedSystemSettings).toHaveBeenCalledTimes(1); }); - test("foreign binding state fails closed before Discovery acquires a lease", async () => { - const provider = createProvider({ id: 1, firstByteTimeoutStreamingMs: 0 }); + test("foreign binding state uses single-upstream routing with serial fallback", async () => { + const provider = createProvider({ id: 1, firstByteTimeoutStreamingMs: 100 }); + const alternative = createProvider({ id: 2, name: "serial-fallback" }); const session = createSession(); session.authState = { success: true, @@ -2571,6 +2572,7 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { reason: "legacy_owner_mismatch", legacyFallbackAllowed: false, }); + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(alternative); const doForward = vi.spyOn( ProxyForwarder as unknown as { @@ -2578,16 +2580,26 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { }, "doForward" ); - doForward.mockResolvedValueOnce( - new Response('data: {"type":"message_stop"}\n\n', { - status: 200, - headers: { "content-type": "text/event-stream" }, - }) - ); + doForward + .mockRejectedValueOnce(new UpstreamProxyError("initial provider failed", 500)) + .mockResolvedValueOnce( + new Response('data: {"type":"message_stop"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + ); - await ProxyForwarder.send(session); - expect(doForward).toHaveBeenCalledTimes(1); + const response = await ProxyForwarder.send(session); + expect(response.status).toBe(200); + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls.map((call) => (call[1] as Provider).id)).toEqual([ + provider.id, + alternative.id, + ]); + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledTimes(1); + expect(session.isStreamingHedgeDisabled()).toBe(true); expect(session.isSessionBindingAllowed()).toBe(false); + expect(peekDeferredStreamingFinalization(session)?.bindingIntent).toBe("none"); expect(mocks.acquireSessionDiscoveryLease).not.toHaveBeenCalled(); expect(mocks.pickDiscoveryProviders).not.toHaveBeenCalled(); }); @@ -2702,6 +2714,243 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { } }); + test("a Sticky rectifier retry stalled in setup still demotes to fallback", async () => { + vi.useFakeTimers(); + try { + const sticky = createProvider({ id: 1, name: "sticky", priority: 1 }); + const normal = createProvider({ id: 2, name: "normal", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 36 }, + apiKey: null, + } as typeof session.authState; + session.request.message.messages = [ + { role: "user", content: "first" }, + { role: "assistant", content: [{ type: "thinking", thinking: "t", signature: "sig" }] }, + ]; + setProviderWithSessionRef(session, sticky); + session.setSessionBindingSnapshot({ + sessionId: session.sessionId!, + keyId: 36, + providerId: sticky.id, + generation: "g-sticky-rectifier-setup", + }); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 50, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([normal]); + + const retrySetup = Promise.withResolvers<{ + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }>(); + const retrySetupStarted = Promise.withResolvers(); + let stickyEndpointCalls = 0; + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === sticky.id) { + stickyEndpointCalls += 1; + if (stickyEndpointCalls > 1) { + retrySetupStarted.resolve(); + return retrySetup.promise; + } + } + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; + }); + + const signatureError = new UpstreamProxyError( + "Invalid `signature` in `thinking` block", + 400, + { + body: '{"error":"invalid_signature"}', + providerId: sticky.id, + providerName: sticky.name, + } + ); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + if ((attemptSession as ProxySession).provider?.id === sticky.id) throw signatureError; + return new Response('data: {"type":"content_block_delta","delta":{"text":"normal"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + await retrySetupStarted.promise; + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.clearVersionedSessionProvider).toHaveBeenCalledWith( + expect.objectContaining({ generation: "g-sticky-rectifier-setup" }), + sticky.id, + 300 + ); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + expect(doForward).toHaveBeenCalledTimes(2); + + retrySetup.resolve({ endpointId: null, baseUrl: sticky.url, endpointUrl: sticky.url }); + await vi.advanceTimersByTimeAsync(0); + const response = await responsePromise; + expect(await response.text()).toContain('"normal"'); + expect(doForward).toHaveBeenCalledTimes(2); + expect(mocks.releaseProviderSession).toHaveBeenCalledWith(sticky.id, session.sessionId); + } finally { + vi.useRealTimers(); + } + }); + + test("a timed-out Sticky retry setup failure starts exactly one full Discovery wave", async () => { + vi.useFakeTimers(); + try { + const sticky = createProvider({ id: 1, name: "sticky", priority: 1 }); + const normalOne = createProvider({ id: 2, name: "normal-one", priority: 1 }); + const normalTwo = createProvider({ id: 3, name: "normal-two", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 38 }, + apiKey: null, + } as typeof session.authState; + session.request.message.messages = [ + { role: "user", content: "first" }, + { role: "assistant", content: [{ type: "thinking", thinking: "t", signature: "sig" }] }, + ]; + setProviderWithSessionRef(session, sticky); + session.setSessionBindingSnapshot({ + sessionId: session.sessionId!, + keyId: 38, + providerId: sticky.id, + generation: "g-sticky-retry-failure", + }); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 50, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([normalOne, normalTwo]); + + const clearBinding = Promise.withResolvers(); + mocks.clearVersionedSessionProvider.mockReturnValueOnce(clearBinding.promise); + const retrySetup = Promise.withResolvers<{ + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }>(); + const retrySetupStarted = Promise.withResolvers(); + let stickyEndpointCalls = 0; + vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ).mockImplementation(async (_attemptSession, provider) => { + if (provider.id === sticky.id && ++stickyEndpointCalls > 1) { + retrySetupStarted.resolve(); + return retrySetup.promise; + } + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; + }); + + const signatureError = new UpstreamProxyError( + "Invalid `signature` in `thinking` block", + 400, + { + body: '{"error":"invalid_signature"}', + providerId: sticky.id, + providerName: sticky.name, + } + ); + const normalTwoResponse = Promise.withResolvers(); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const providerId = (attemptSession as ProxySession).provider?.id; + if (providerId === sticky.id) throw signatureError; + if (providerId === normalTwo.id) return normalTwoResponse.promise; + return new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + await retrySetupStarted.promise; + await vi.advanceTimersByTimeAsync(10); + retrySetup.reject(new Error("sticky retry setup failed")); + await vi.advanceTimersByTimeAsync(0); + // The failed rectifier setup frees the fallback slot, but the reserved + // wave remains behind the Sticky binding-clear CAS. + expect(mocks.pickDiscoveryProviders).not.toHaveBeenCalled(); + + clearBinding.resolve({ + status: "ok", + legacyFallbackAllowed: false, + source: "cleared", + snapshot: { + sessionId: session.sessionId!, + keyId: 38, + providerId: null, + generation: "g-sticky-retry-failure-cleared", + }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledWith( + expect.anything(), + 2, + expect.arrayContaining([sticky.id]) + ); + normalTwoResponse.resolve( + new Response('data: {"type":"content_block_delta","delta":{"text":"winner"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }) + ); + await vi.advanceTimersByTimeAsync(0); + + const response = await responsePromise; + expect(await response.text()).toContain('"winner"'); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + expect(doForward).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + test("Sticky timeout and fallback failure consume a single replacement-wave reservation", async () => { vi.useFakeTimers(); try { @@ -2729,7 +2978,7 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { mocks.getCachedSystemSettings.mockResolvedValue({ discoveryEnabled: true, discoveryConcurrency: 2, - maxDiscoveryRounds: 2, + maxDiscoveryRounds: 1, discoverySlaMs: 50, stickySlaMs: 10, racingTotalTimeoutMs: 200, @@ -2768,15 +3017,6 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { expect(mocks.clearVersionedSessionProvider).toHaveBeenCalledTimes(1); expect(mocks.pickDiscoveryProviders).not.toHaveBeenCalled(); - stickyAttempt.reject(new Error("Sticky fallback failed while binding clear was pending")); - await vi.advanceTimersByTimeAsync(0); - expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); - expect(mocks.pickDiscoveryProviders).toHaveBeenCalledWith( - expect.anything(), - 2, - expect.arrayContaining([sticky.id]) - ); - clearBinding.resolve({ status: "ok", legacyFallbackAllowed: false, @@ -2788,8 +3028,17 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { generation: "g-cleared-single-wave", }, }); + // Resolve the cooldown and fail the fallback in the same turn. This + // exercises the claim-to-queue-start window: the claimed N-1 wave must + // remain expandable to full concurrency before placeholders register. + stickyAttempt.reject(new Error("Sticky fallback failed while the wave was being claimed")); await vi.advanceTimersByTimeAsync(0); expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledWith( + expect.anything(), + 2, + expect.arrayContaining([sticky.id]) + ); replacementWave.resolve([normalOne, normalTwo]); await vi.advanceTimersByTimeAsync(0); @@ -2926,16 +3175,15 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { } }); - test("client abort preserves an already-reserved Sticky timeout cooldown", async () => { + test("a racing deadline bounds a stalled Sticky cooldown cleanup", async () => { vi.useFakeTimers(); try { - const clientAbort = new AbortController(); const sticky = createProvider({ id: 1, name: "sticky", priority: 1 }); - const session = createSession(clientAbort.signal); + const session = createSession(); session.authState = { success: true, user: null, - key: { id: 33 }, + key: { id: 39 }, apiKey: null, } as typeof session.authState; session.request.message.messages = [ @@ -2945,32 +3193,20 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { session.setProvider(sticky); session.setSessionBindingSnapshot({ sessionId: session.sessionId!, - keyId: 33, + keyId: 39, providerId: sticky.id, - generation: "g-sticky-cooldown-abort", + generation: "g-sticky-cleanup-bound", }); mocks.getCachedSystemSettings.mockResolvedValue({ discoveryEnabled: true, discoveryConcurrency: 2, maxDiscoveryRounds: 1, - discoverySlaMs: 50, + discoverySlaMs: 20, stickySlaMs: 10, - racingTotalTimeoutMs: 100, + racingTotalTimeoutMs: 30, stickyTimeoutCooldownMs: 300_000, }); - - const cooldownClear = Promise.withResolvers<{ - status: "ok"; - legacyFallbackAllowed: false; - source: "cleared"; - snapshot: { - sessionId: string; - keyId: number; - providerId: null; - generation: string; - }; - }>(); - mocks.clearVersionedSessionProvider.mockReturnValueOnce(cooldownClear.promise); + mocks.clearVersionedSessionProvider.mockReturnValueOnce(new Promise(() => {})); vi.spyOn( ProxyForwarder as unknown as { doForward: (...args: unknown[]) => Promise; @@ -2982,28 +3218,101 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { }) ); - let requestSettled = false; - const observed = ProxyForwarder.send(session).catch((error) => { - requestSettled = true; - return error; + const observed = ProxyForwarder.send(session).catch((error) => error); + await vi.advanceTimersByTimeAsync(30); + let settled = false; + void observed.then(() => { + settled = true; }); - await vi.advanceTimersByTimeAsync(10); - clientAbort.abort(new Error("client disconnected")); - await vi.advanceTimersByTimeAsync(0); - - expect(requestSettled).toBe(false); - expect(mocks.clearVersionedSessionProvider).toHaveBeenCalledOnce(); - expect(mocks.clearVersionedSessionProvider).toHaveBeenCalledWith( - expect.objectContaining({ generation: "g-sticky-cooldown-abort" }), - sticky.id, - 300 - ); + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); - cooldownClear.resolve({ - status: "ok", - legacyFallbackAllowed: false, - source: "cleared", - snapshot: { + await vi.advanceTimersByTimeAsync(1); + expect(await observed).toBeInstanceOf(UpstreamProxyError); + expect(mocks.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + test("client abort preserves an already-reserved Sticky timeout cooldown", async () => { + vi.useFakeTimers(); + try { + const clientAbort = new AbortController(); + const sticky = createProvider({ id: 1, name: "sticky", priority: 1 }); + const session = createSession(clientAbort.signal); + session.authState = { + success: true, + user: null, + key: { id: 33 }, + apiKey: null, + } as typeof session.authState; + session.request.message.messages = [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ]; + session.setProvider(sticky); + session.setSessionBindingSnapshot({ + sessionId: session.sessionId!, + keyId: 33, + providerId: sticky.id, + generation: "g-sticky-cooldown-abort", + }); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 50, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + }); + + const cooldownClear = Promise.withResolvers<{ + status: "ok"; + legacyFallbackAllowed: false; + source: "cleared"; + snapshot: { + sessionId: string; + keyId: number; + providerId: null; + generation: string; + }; + }>(); + mocks.clearVersionedSessionProvider.mockReturnValueOnce(cooldownClear.promise); + vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ).mockResolvedValueOnce( + new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }) + ); + + let requestSettled = false; + const observed = ProxyForwarder.send(session).catch((error) => { + requestSettled = true; + return error; + }); + await vi.advanceTimersByTimeAsync(10); + clientAbort.abort(new Error("client disconnected")); + await vi.advanceTimersByTimeAsync(0); + + expect(requestSettled).toBe(true); + expect(mocks.clearVersionedSessionProvider).toHaveBeenCalledOnce(); + expect(mocks.clearVersionedSessionProvider).toHaveBeenCalledWith( + expect.objectContaining({ generation: "g-sticky-cooldown-abort" }), + sticky.id, + 300 + ); + + cooldownClear.resolve({ + status: "ok", + legacyFallbackAllowed: false, + source: "cleared", + snapshot: { sessionId: session.sessionId!, keyId: 33, providerId: null, @@ -3680,38 +3989,40 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { } }); - test("a fallback failure waits for the reserved wave before advancing another round", async () => { + test("Discovery round SLA advances while the initial candidate selector is stalled", async () => { vi.useFakeTimers(); try { - const fallback = createProvider({ id: 1, name: "fallback", priority: 1 }); - const firstRoundLoser = createProvider({ id: 2, name: "first-round-loser", priority: 1 }); - const nextRoundWinner = createProvider({ id: 3, name: "next-round-winner", priority: 1 }); + const initial = createProvider({ id: 1, name: "initial", priority: 1 }); + const winner = createProvider({ id: 2, name: "next-round", priority: 1 }); + const stale = createProvider({ id: 3, name: "stale", priority: 1 }); const session = createSession(); session.authState = { success: true, user: null, - key: { id: 26 }, + key: { id: 39 }, apiKey: null, } as typeof session.authState; - session.setProvider(fallback); + session.setProvider(initial); mocks.getCachedSystemSettings.mockResolvedValue({ discoveryEnabled: true, discoveryConcurrency: 2, - maxDiscoveryRounds: 3, + maxDiscoveryRounds: 2, discoverySlaMs: 10, stickySlaMs: 10, racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, }); - const reservedWave = Promise.withResolvers(); + const stalledSelector = Promise.withResolvers(); + const selectorStarted = Promise.withResolvers(); mocks.pickDiscoveryProviders - .mockResolvedValueOnce([firstRoundLoser]) - .mockReturnValueOnce(reservedWave.promise) - .mockResolvedValueOnce([ - createProvider({ id: 4, name: "unexpected-extra-round", priority: 1 }), - ]); + .mockImplementationOnce(() => { + selectorStarted.resolve(); + return stalledSelector.promise; + }) + .mockResolvedValueOnce([winner]); - const fallbackFailure = Promise.withResolvers(); + const launchedProviderIds: number[] = []; const doForward = vi.spyOn( ProxyForwarder as unknown as { doForward: (...args: unknown[]) => Promise; @@ -3719,13 +4030,12 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { "doForward" ); doForward.mockImplementation(async (attemptSession) => { - const providerId = (attemptSession as ProxySession).provider?.id; - if (providerId === fallback.id) return fallbackFailure.promise; - if (providerId === nextRoundWinner.id) { - return new Response( - 'data: {"type":"content_block_delta","delta":{"text":"winner"}}\n\n', - { headers: { "content-type": "text/event-stream" } } - ); + const providerId = (attemptSession as ProxySession).provider!.id; + launchedProviderIds.push(providerId); + if (providerId === winner.id) { + return new Response('data: {"type":"content_block_delta","delta":{"text":"next"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); } return new Response(new ReadableStream(), { headers: { "content-type": "text/event-stream" }, @@ -3733,120 +4043,1067 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { }); const responsePromise = ProxyForwarder.send(session); + await selectorStarted.promise; await vi.advanceTimersByTimeAsync(10); - expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); - - fallbackFailure.reject(new Error("fallback failed during reserved wave")); await vi.advanceTimersByTimeAsync(0); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); + const response = await responsePromise; + expect(await response.text()).toContain('"next"'); - reservedWave.resolve([nextRoundWinner]); + stalledSelector.resolve([stale]); await vi.advanceTimersByTimeAsync(0); - const response = await responsePromise; - expect(await response.text()).toContain('"winner"'); - expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); - expect(doForward).toHaveBeenCalledTimes(3); + expect(launchedProviderIds).toEqual([initial.id, winner.id]); + expect(doForward).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); } }); - test("Discovery does not immediately reselect a Provider whose launch setup failed", async () => { - const initial = createProvider({ id: 1, name: "initial" }); - const alternative = createProvider({ id: 2, name: "alternative" }); - const session = createSession(); - session.authState = { - success: true, - user: null, - key: { id: 20 }, - apiKey: null, - } as typeof session.authState; - session.setProvider(initial); - mocks.getCachedSystemSettings.mockResolvedValue({ - discoveryEnabled: true, - discoveryConcurrency: 2, - maxDiscoveryRounds: 1, - discoverySlaMs: 50, - stickySlaMs: 50, - racingTotalTimeoutMs: 200, - stickyTimeoutCooldownMs: 300_000, - }); - mocks.pickDiscoveryProviders.mockImplementationOnce( - async (_session: ProxySession, _count: number, excludedIds: number[]) => { - expect(excludedIds).toContain(initial.id); - return [alternative]; - } - ); + test("a ready fallback stays held until the queued next wave registers", async () => { + vi.useFakeTimers(); + let endpointResolver: ReturnType | null = null; + try { + const fallback = createProvider({ id: 1, name: "fallback", priority: 1 }); + const setup = createProvider({ + id: 2, + name: "cancelled-setup", + priority: 1, + limitConcurrentSessions: 1, + }); + const winner = createProvider({ id: 3, name: "next-round-winner", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 40 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(fallback); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([setup]).mockResolvedValueOnce([winner]); - const endpointResolver = vi.spyOn( - ProxyForwarder as unknown as { - resolveStreamingHedgeEndpoint: ( - session: ProxySession, - provider: Provider - ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; - }, - "resolveStreamingHedgeEndpoint" - ); - endpointResolver - .mockRejectedValueOnce(new Error("initial endpoint setup failed")) - .mockResolvedValue({ - endpointId: null, - baseUrl: alternative.url, - endpointUrl: alternative.url, + endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === setup.id) return new Promise(() => {}); + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; }); - const doForward = vi.spyOn( - ProxyForwarder as unknown as { - doForward: (...args: unknown[]) => Promise; - }, - "doForward" - ); - doForward.mockResolvedValueOnce( - new Response('data: {"type":"content_block_delta","delta":{"text":"alternative"}}\n\n', { - headers: { "content-type": "text/event-stream" }, - }) - ); + let fallbackController: ReadableStreamDefaultController | null = null; + const launchedProviderIds: number[] = []; + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const providerId = (attemptSession as ProxySession).provider!.id; + launchedProviderIds.push(providerId); + if (providerId === winner.id) { + return new Response( + 'data: {"type":"content_block_delta","delta":{"text":"winner"}}\n\n', + { headers: { "content-type": "text/event-stream" } } + ); + } + return new Response( + new ReadableStream({ + start(controller) { + fallbackController = controller; + }, + }), + { headers: { "content-type": "text/event-stream" } } + ); + }); + mocks.releaseProviderSession.mockImplementation(async (providerId) => { + if (providerId !== setup.id || !fallbackController) return; + fallbackController.enqueue( + new TextEncoder().encode( + 'data: {"type":"content_block_delta","delta":{"text":"fallback"}}\n\n' + ) + ); + fallbackController.close(); + }); - try { - const response = await ProxyForwarder.send(session); - expect(await response.text()).toContain('"alternative"'); - expect(doForward).toHaveBeenCalledTimes(1); - expect(session.provider?.id).toBe(alternative.id); + const responsePromise = ProxyForwarder.send(session); + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(0); + + const response = await responsePromise; + expect(await response.text()).toContain('"winner"'); + expect(launchedProviderIds).toEqual([fallback.id, winner.id]); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); + expect(mocks.releaseProviderSession).toHaveBeenCalledWith(setup.id, session.sessionId); } finally { - endpointResolver.mockRestore(); + endpointResolver?.mockRestore(); + mocks.releaseProviderSession.mockImplementation(async () => {}); + vi.useRealTimers(); } }); - test("Discovery transfers the Provider session ref when a rectifier retries the same Provider", async () => { - const initial = createProvider({ id: 1, name: "initial", limitConcurrentSessions: 1 }); - const alternative = createProvider({ id: 2, name: "alternative", limitConcurrentSessions: 1 }); - const session = createSession(); - session.authState = { - success: true, - user: null, - key: { id: 22 }, - apiKey: null, - } as typeof session.authState; - setProviderWithSessionRef(session, initial); - withThinkingBlocks(session); - mocks.getCachedSystemSettings.mockResolvedValue({ - discoveryEnabled: true, - discoveryConcurrency: 2, - maxDiscoveryRounds: 1, + test("a failing fallback waits for the queued next wave handoff", async () => { + vi.useFakeTimers(); + let endpointResolver: ReturnType | null = null; + try { + const fallback = createProvider({ id: 1, name: "fallback", priority: 1 }); + const setup = createProvider({ id: 2, name: "cancelled-setup", priority: 1 }); + const stalled = createProvider({ id: 3, name: "stalled-next-wave", priority: 1 }); + const winner = createProvider({ id: 4, name: "error-refill-winner", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 41 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(fallback); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + }); + const stalledNextWave = Promise.withResolvers(); + const stalledNextWaveStarted = Promise.withResolvers(); + mocks.pickDiscoveryProviders + .mockResolvedValueOnce([setup]) + .mockImplementationOnce(() => { + stalledNextWaveStarted.resolve(); + return stalledNextWave.promise; + }) + .mockResolvedValueOnce([winner]); + + endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === setup.id) return new Promise(() => {}); + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; + }); + + const fallbackFailure = Promise.withResolvers(); + const launchedProviderIds: number[] = []; + vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ).mockImplementation(async (attemptSession) => { + const providerId = (attemptSession as ProxySession).provider!.id; + launchedProviderIds.push(providerId); + if (providerId === fallback.id) return fallbackFailure.promise; + return new Response('data: {"type":"content_block_delta","delta":{"text":"winner"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + }); + const responsePromise = ProxyForwarder.send(session); + await vi.advanceTimersByTimeAsync(10); + await stalledNextWaveStarted.promise; + fallbackFailure.reject(new Error("fallback failed after queued-wave handoff")); + await vi.advanceTimersByTimeAsync(0); + + const response = await responsePromise; + expect(await response.text()).toContain('"winner"'); + expect(launchedProviderIds).toEqual([fallback.id, winner.id]); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(3); + + stalledNextWave.resolve([stalled]); + await vi.advanceTimersByTimeAsync(0); + expect(launchedProviderIds).toEqual([fallback.id, winner.id]); + } finally { + endpointResolver?.mockRestore(); + vi.useRealTimers(); + } + }); + + test("a stale candidate selector cannot launch after its Discovery round closes", async () => { + vi.useFakeTimers(); + try { + const initial = createProvider({ id: 1, name: "initial", priority: 1 }); + const peer = createProvider({ id: 2, name: "peer", priority: 1 }); + const nextRound = createProvider({ id: 3, name: "next-round", priority: 1 }); + const stale = createProvider({ id: 4, name: "stale", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 37 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(initial); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + }); + const staleSelector = Promise.withResolvers(); + const refillStarted = Promise.withResolvers(); + mocks.pickDiscoveryProviders + .mockResolvedValueOnce([peer]) + .mockImplementationOnce(() => { + refillStarted.resolve(); + return staleSelector.promise; + }) + .mockResolvedValueOnce([nextRound]); + + const launchedProviderIds: number[] = []; + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const providerId = (attemptSession as ProxySession).provider!.id; + launchedProviderIds.push(providerId); + if (providerId === initial.id) throw new Error("initial failed"); + if (providerId === nextRound.id) { + return new Response('data: {"type":"content_block_delta","delta":{"text":"next"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + await refillStarted.promise; + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(3); + + staleSelector.resolve([stale]); + await vi.advanceTimersByTimeAsync(0); + + const response = await responsePromise; + expect(await response.text()).toContain('"next"'); + expect(launchedProviderIds).toEqual([initial.id, peer.id, nextRound.id]); + expect(doForward).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + test("a fallback failure refills the reserved round without waiting for its stalled selector", async () => { + vi.useFakeTimers(); + try { + const fallback = createProvider({ id: 1, name: "fallback", priority: 1 }); + const firstRoundLoser = createProvider({ id: 2, name: "first-round-loser", priority: 1 }); + const nextRoundWinner = createProvider({ id: 3, name: "next-round-winner", priority: 1 }); + const staleReservedCandidate = createProvider({ + id: 4, + name: "stale-reserved-candidate", + priority: 1, + }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 26 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(fallback); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 3, + discoverySlaMs: 10, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + }); + + const reservedWave = Promise.withResolvers(); + mocks.pickDiscoveryProviders + .mockResolvedValueOnce([firstRoundLoser]) + .mockReturnValueOnce(reservedWave.promise) + .mockResolvedValueOnce([nextRoundWinner]); + + const fallbackFailure = Promise.withResolvers(); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const providerId = (attemptSession as ProxySession).provider?.id; + if (providerId === fallback.id) return fallbackFailure.promise; + if (providerId === nextRoundWinner.id) { + return new Response( + 'data: {"type":"content_block_delta","delta":{"text":"winner"}}\n\n', + { headers: { "content-type": "text/event-stream" } } + ); + } + return new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + await vi.advanceTimersByTimeAsync(10); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); + + fallbackFailure.reject(new Error("fallback failed during reserved wave")); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(3); + + const response = await responsePromise; + expect(await response.text()).toContain('"winner"'); + + reservedWave.resolve([staleReservedCandidate]); + await vi.advanceTimersByTimeAsync(0); + expect(doForward).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + test("Discovery does not immediately reselect a Provider whose launch setup failed", async () => { + const initial = createProvider({ id: 1, name: "initial" }); + const alternative = createProvider({ id: 2, name: "alternative" }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 20 }, + apiKey: null, + } as typeof session.authState; + session.setProvider(initial); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 50, + stickySlaMs: 50, + racingTotalTimeoutMs: 200, + stickyTimeoutCooldownMs: 300_000, + }); + mocks.pickDiscoveryProviders.mockImplementationOnce( + async (_session: ProxySession, _count: number, excludedIds: number[]) => { + expect(excludedIds).toContain(initial.id); + return [alternative]; + } + ); + + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver + .mockRejectedValueOnce(new Error("initial endpoint setup failed")) + .mockResolvedValue({ + endpointId: null, + baseUrl: alternative.url, + endpointUrl: alternative.url, + }); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockResolvedValueOnce( + new Response('data: {"type":"content_block_delta","delta":{"text":"alternative"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }) + ); + + try { + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain('"alternative"'); + expect(doForward).toHaveBeenCalledTimes(1); + expect(session.provider?.id).toBe(alternative.id); + } finally { + endpointResolver.mockRestore(); + } + }); + + test("Discovery transfers the Provider session ref when a rectifier retries the same Provider", async () => { + const initial = createProvider({ id: 1, name: "initial", limitConcurrentSessions: 1 }); + const alternative = createProvider({ id: 2, name: "alternative", limitConcurrentSessions: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 22 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, initial); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 100, + stickySlaMs: 100, + racingTotalTimeoutMs: 500, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([alternative]); + + const signatureError = new UpstreamProxyError("Invalid `signature` in `thinking` block", 400, { + body: '{"error":"invalid_signature"}', + providerId: initial.id, + providerName: initial.name, + }); + let initialAttempts = 0; + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + if (runtime.provider?.id === initial.id) { + initialAttempts += 1; + if (initialAttempts === 1) throw signatureError; + + const body = runtime.request.message as { + messages: Array<{ content: Array> }>; + }; + expect(body.messages[0].content.some((block) => "signature" in block)).toBe(false); + return new Response( + 'data: {"type":"content_block_delta","delta":{"text":"rectified"}}\n\n', + { headers: { "content-type": "text/event-stream" } } + ); + } + + return new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }); + }); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain('"rectified"'); + + const initialAdmissionCalls = mocks.checkAndTrackProviderSession.mock.calls.filter( + ([providerId]) => providerId === initial.id + ); + const initialReleaseCalls = mocks.releaseProviderSession.mock.calls.filter( + ([providerId]) => providerId === initial.id + ); + expect(initialAttempts).toBe(2); + expect(initialAdmissionCalls).toHaveLength(0); + expect(initialReleaseCalls).toHaveLength(0); + expect(session.hasProviderSessionRef(initial.id)).toBe(true); + }); + + test("Discovery keeps a healthy peer when rectifier retry setup fails", async () => { + const initial = createProvider({ id: 1, name: "initial", limitConcurrentSessions: 1 }); + const alternative = createProvider({ id: 2, name: "alternative" }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 24 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, initial); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 100, + stickySlaMs: 100, + racingTotalTimeoutMs: 500, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([alternative]); + + let initialEndpointAttempts = 0; + let allowAlternativeResponse!: () => void; + const rectifierRetrySetupAttempted = new Promise((resolve) => { + allowAlternativeResponse = resolve; + }); + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === initial.id) { + initialEndpointAttempts += 1; + if (initialEndpointAttempts > 1) { + allowAlternativeResponse(); + throw new Error("rectifier retry endpoint setup failed"); + } + } + return { + endpointId: null, + baseUrl: provider.url, + endpointUrl: provider.url, + }; + }); + + const signatureError = new UpstreamProxyError("Invalid `signature` in `thinking` block", 400, { + body: '{"error":"invalid_signature"}', + providerId: initial.id, + providerName: initial.name, + }); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const provider = (attemptSession as ProxySession).provider; + if (provider?.id === initial.id) throw signatureError; + await rectifierRetrySetupAttempted; + return new Response( + 'data: {"type":"content_block_delta","delta":{"text":"alternative"}}\n\n', + { headers: { "content-type": "text/event-stream" } } + ); + }); + + try { + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain('"alternative"'); + expect(initialEndpointAttempts).toBe(2); + expect(session.provider?.id).toBe(alternative.id); + } finally { + endpointResolver.mockRestore(); + } + }); + + test("a rectifier retry setup reservation cannot overfill the next Discovery round", async () => { + vi.useFakeTimers(); + try { + const retrying = createProvider({ + id: 1, + name: "retrying", + priority: 10, + limitConcurrentSessions: 1, + }); + const fallback = createProvider({ id: 2, name: "fallback", priority: 1 }); + const nextRound = createProvider({ id: 3, name: "next-round", priority: 1 }); + const unexpected = createProvider({ id: 4, name: "unexpected", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 31 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, retrying); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders + .mockResolvedValueOnce([fallback]) + .mockResolvedValueOnce([nextRound]) + .mockResolvedValueOnce([unexpected]); + + const retrySetup = Promise.withResolvers<{ + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }>(); + const retrySetupStarted = Promise.withResolvers(); + let retryingEndpointCalls = 0; + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === retrying.id) { + retryingEndpointCalls += 1; + if (retryingEndpointCalls > 1) { + retrySetupStarted.resolve(); + return retrySetup.promise; + } + } + return { + endpointId: null, + baseUrl: provider.url, + endpointUrl: provider.url, + }; + }); + + const signatureError = new UpstreamProxyError( + "Invalid `signature` in `thinking` block", + 400, + { + body: '{"error":"invalid_signature"}', + providerId: retrying.id, + providerName: retrying.name, + } + ); + const nextRoundResponse = Promise.withResolvers(); + const activeProviders = new Set(); + let maxActive = 0; + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession, ...args) => { + const providerId = (attemptSession as ProxySession).provider!.id; + const signal = args.at(-1) as AbortSignal; + activeProviders.add(providerId); + maxActive = Math.max(maxActive, activeProviders.size); + signal.addEventListener("abort", () => activeProviders.delete(providerId), { once: true }); + if (providerId === retrying.id) { + activeProviders.delete(providerId); + throw signatureError; + } + if (providerId === nextRound.id) return nextRoundResponse.promise; + return new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + await retrySetupStarted.promise; + await vi.advanceTimersByTimeAsync(10); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); + expect(maxActive).toBeLessThanOrEqual(2); + + retrySetup.resolve({ + endpointId: null, + baseUrl: retrying.url, + endpointUrl: retrying.url, + }); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(2); + expect(doForward).toHaveBeenCalledTimes(3); + + nextRoundResponse.resolve( + new Response('data: {"type":"content_block_delta","delta":{"text":"next"}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }) + ); + await vi.advanceTimersByTimeAsync(0); + const response = await responsePromise; + expect(await response.text()).toContain('"next"'); + expect(maxActive).toBeLessThanOrEqual(2); + } finally { + vi.useRealTimers(); + } + }); + + test("the final Discovery boundary cancels a stalled rectifier retry without refilling", async () => { + vi.useFakeTimers(); + try { + const retrying = createProvider({ + id: 1, + name: "retrying", + priority: 10, + limitConcurrentSessions: 1, + }); + const fallback = createProvider({ id: 2, name: "fallback", priority: 1 }); + const unexpected = createProvider({ id: 3, name: "unexpected", priority: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 32 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, retrying); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 10, + stickySlaMs: 10, + racingTotalTimeoutMs: 100, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders + .mockResolvedValueOnce([fallback]) + .mockResolvedValueOnce([unexpected]); + + const retrySetup = Promise.withResolvers<{ + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }>(); + const retrySetupStarted = Promise.withResolvers(); + let retryingEndpointCalls = 0; + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === retrying.id) { + retryingEndpointCalls += 1; + if (retryingEndpointCalls > 1) { + retrySetupStarted.resolve(); + return retrySetup.promise; + } + } + return { + endpointId: null, + baseUrl: provider.url, + endpointUrl: provider.url, + }; + }); + + const signatureError = new UpstreamProxyError( + "Invalid `signature` in `thinking` block", + 400, + { + body: '{"error":"invalid_signature"}', + providerId: retrying.id, + providerName: retrying.name, + } + ); + let fallbackController: ReadableStreamDefaultController | null = null; + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockImplementation(async (attemptSession) => { + const providerId = (attemptSession as ProxySession).provider!.id; + if (providerId === retrying.id) throw signatureError; + return new Response( + new ReadableStream({ + start(controller) { + fallbackController = controller; + }, + }), + { headers: { "content-type": "text/event-stream" } } + ); + }); + + const responsePromise = ProxyForwarder.send(session); + await retrySetupStarted.promise; + await vi.advanceTimersByTimeAsync(10); + retrySetup.resolve({ + endpointId: null, + baseUrl: retrying.url, + endpointUrl: retrying.url, + }); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + expect(doForward).toHaveBeenCalledTimes(2); + + fallbackController!.enqueue( + new TextEncoder().encode( + 'data: {"type":"content_block_delta","delta":{"text":"fallback"}}\n\n' + ) + ); + fallbackController!.close(); + await vi.advanceTimersByTimeAsync(0); + const response = await responsePromise; + expect(peekDeferredStreamingFinalization(session)).toEqual( + expect.objectContaining({ + bindingIntent: "none", + requiresCompletionMarker: true, + }) + ); + expect(await response.text()).toContain('"fallback"'); + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + test("the Discovery deadline releases a stalled rectifier retry reservation exactly once", async () => { + vi.useFakeTimers(); + try { + const provider = createProvider({ id: 1, name: "retrying", limitConcurrentSessions: 1 }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 33 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, provider); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 100, + stickySlaMs: 100, + racingTotalTimeoutMs: 50, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([]); + + const retrySetup = Promise.withResolvers<{ + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }>(); + const retrySetupStarted = Promise.withResolvers(); + let endpointCalls = 0; + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async () => { + endpointCalls += 1; + if (endpointCalls > 1) { + retrySetupStarted.resolve(); + return retrySetup.promise; + } + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; + }); + + const signatureError = new UpstreamProxyError( + "Invalid `signature` in `thinking` block", + 400, + { + body: '{"error":"invalid_signature"}', + providerId: provider.id, + providerName: provider.name, + } + ); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockRejectedValueOnce(signatureError); + + const observedError = ProxyForwarder.send(session).catch((error) => error); + await retrySetupStarted.promise; + await vi.advanceTimersByTimeAsync(50); + + expect(await observedError).toBeInstanceOf(UpstreamProxyError); + expect(mocks.releaseProviderSession).toHaveBeenCalledTimes(1); + expect(session.hasProviderSessionRef(provider.id)).toBe(false); + + retrySetup.resolve({ endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }); + await vi.advanceTimersByTimeAsync(0); + expect(doForward).toHaveBeenCalledTimes(1); + expect(mocks.releaseProviderSession).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + test("client abort cancels a stalled rectifier retry setup and releases its ref", async () => { + vi.useFakeTimers(); + try { + const clientAbort = new AbortController(); + const provider = createProvider({ id: 1, name: "retrying", limitConcurrentSessions: 1 }); + const session = createSession(clientAbort.signal); + session.authState = { + success: true, + user: null, + key: { id: 35 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, provider); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 1, + discoverySlaMs: 100, + stickySlaMs: 100, + racingTotalTimeoutMs: 500, + stickyTimeoutCooldownMs: 300_000, + enableThinkingSignatureRectifier: true, + }); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([]); + + const retrySetup = Promise.withResolvers<{ + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }>(); + const retrySetupStarted = Promise.withResolvers(); + let endpointCalls = 0; + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async () => { + endpointCalls += 1; + if (endpointCalls > 1) { + retrySetupStarted.resolve(); + return retrySetup.promise; + } + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; + }); + + const signatureError = new UpstreamProxyError( + "Invalid `signature` in `thinking` block", + 400, + { + body: '{"error":"invalid_signature"}', + providerId: provider.id, + providerName: provider.name, + } + ); + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + doForward.mockRejectedValueOnce(signatureError); + + const observedError = ProxyForwarder.send(session).catch((error) => error); + await retrySetupStarted.promise; + clientAbort.abort(); + await vi.advanceTimersByTimeAsync(0); + + expect(await observedError).toBeInstanceOf(UpstreamProxyError); + expect(mocks.releaseProviderSession).toHaveBeenCalledTimes(1); + expect(session.hasProviderSessionRef(provider.id)).toBe(false); + + retrySetup.resolve({ endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }); + await vi.advanceTimersByTimeAsync(0); + expect(doForward).toHaveBeenCalledTimes(1); + expect(mocks.releaseProviderSession).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + test.each([ + { + label: "client abort", + category: ProxyErrorCategory.CLIENT_ABORT, + setupError: new Error("retry setup observed client abort"), + expectedStatus: 499, + }, + { + label: "local overload", + category: ProxyErrorCategory.LOCAL_OVERLOAD, + setupError: new DbPoolAdmissionError("data", 32), + expectedStatus: null, + }, + ])("rectifier retry setup preserves $label fail-fast semantics", async (scenario) => { + const retrying = createProvider({ id: 1, name: "retrying", limitConcurrentSessions: 1 }); + const peer = createProvider({ id: 2, name: "peer" }); + const unexpected = createProvider({ id: 3, name: "unexpected" }); + const session = createSession(); + session.authState = { + success: true, + user: null, + key: { id: 34 }, + apiKey: null, + } as typeof session.authState; + setProviderWithSessionRef(session, retrying); + withThinkingBlocks(session); + mocks.getCachedSystemSettings.mockResolvedValue({ + discoveryEnabled: true, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, discoverySlaMs: 100, stickySlaMs: 100, racingTotalTimeoutMs: 500, stickyTimeoutCooldownMs: 300_000, enableThinkingSignatureRectifier: true, }); - mocks.pickDiscoveryProviders.mockResolvedValueOnce([alternative]); + mocks.pickDiscoveryProviders.mockResolvedValueOnce([peer]).mockResolvedValueOnce([unexpected]); + mocks.categorizeErrorAsync + .mockResolvedValueOnce(ProxyErrorCategory.PROVIDER_ERROR) + .mockResolvedValueOnce(scenario.category); + + let retryingEndpointCalls = 0; + const endpointResolver = vi.spyOn( + ProxyForwarder as unknown as { + resolveStreamingHedgeEndpoint: ( + session: ProxySession, + provider: Provider + ) => Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }>; + }, + "resolveStreamingHedgeEndpoint" + ); + endpointResolver.mockImplementation(async (_attemptSession, provider) => { + if (provider.id === retrying.id) { + retryingEndpointCalls += 1; + if (retryingEndpointCalls > 1) throw scenario.setupError; + } + return { endpointId: null, baseUrl: provider.url, endpointUrl: provider.url }; + }); const signatureError = new UpstreamProxyError("Invalid `signature` in `thinking` block", 400, { body: '{"error":"invalid_signature"}', - providerId: initial.id, - providerName: initial.name, + providerId: retrying.id, + providerName: retrying.name, }); - let initialAttempts = 0; const doForward = vi.spyOn( ProxyForwarder as unknown as { doForward: (...args: unknown[]) => Promise; @@ -3854,39 +5111,25 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { "doForward" ); doForward.mockImplementation(async (attemptSession) => { - const runtime = attemptSession as ProxySession & AttemptRuntime; - if (runtime.provider?.id === initial.id) { - initialAttempts += 1; - if (initialAttempts === 1) throw signatureError; - - const body = runtime.request.message as { - messages: Array<{ content: Array> }>; - }; - expect(body.messages[0].content.some((block) => "signature" in block)).toBe(false); - return new Response( - 'data: {"type":"content_block_delta","delta":{"text":"rectified"}}\n\n', - { headers: { "content-type": "text/event-stream" } } - ); - } - + if ((attemptSession as ProxySession).provider?.id === retrying.id) throw signatureError; return new Response(new ReadableStream(), { headers: { "content-type": "text/event-stream" }, }); }); - const response = await ProxyForwarder.send(session); - expect(await response.text()).toContain('"rectified"'); - - const initialAdmissionCalls = mocks.checkAndTrackProviderSession.mock.calls.filter( - ([providerId]) => providerId === initial.id - ); - const initialReleaseCalls = mocks.releaseProviderSession.mock.calls.filter( - ([providerId]) => providerId === initial.id - ); - expect(initialAttempts).toBe(2); - expect(initialAdmissionCalls).toHaveLength(0); - expect(initialReleaseCalls).toHaveLength(0); - expect(session.hasProviderSessionRef(initial.id)).toBe(true); + const observed = await ProxyForwarder.send(session).catch((error) => error); + if (scenario.expectedStatus == null) { + expect(observed).toBe(scenario.setupError); + } else { + expect(observed).toBeInstanceOf(UpstreamProxyError); + expect((observed as UpstreamProxyError).statusCode).toBe(scenario.expectedStatus); + } + expect(mocks.pickDiscoveryProviders).toHaveBeenCalledTimes(1); + expect(doForward).toHaveBeenCalledTimes(2); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect( + mocks.releaseProviderSession.mock.calls.filter(([providerId]) => providerId === retrying.id) + ).toHaveLength(1); }); test("Discovery releases a transferred Provider session ref exactly once when rectifier retry setup fails", async () => { diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 432881396..4681b6e47 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -841,6 +841,44 @@ describe("Endpoint circuit breaker isolation", () => { expect(RateLimitService.releaseProviderSession).toHaveBeenCalledWith(1, "fake-session"); }); + it("records a Discovery fallback without a completion marker as failed", async () => { + const session = createSession(); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 2, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: true, + endpointId: 42, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 200, + bindingIntent: "none", + requiresCompletionMarker: true, + }); + + const clientResponse = await ProxyResponseHandler.dispatch( + session, + createSuccessStreamResponse() + ); + await clientResponse.text(); + await drainAsyncTasks(); + + expect(updateMessageRequestDetailsDurably).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + statusCode: 502, + errorMessage: "STREAM_COMPLETION_MARKER_MISSING", + }), + expect.objectContaining({ onCommitted: expect.any(Function) }) + ); + expect(mockRecordFailure).toHaveBeenCalledOnce(); + expect(mockRecordSuccess).not.toHaveBeenCalled(); + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.clearVersionedSessionProvider).not.toHaveBeenCalled(); + }); + it("does not clear a create tombstone when the completion marker is missing", async () => { const session = createSession(); setDeferredStreamingFinalization(session, { @@ -917,7 +955,65 @@ describe("Endpoint circuit breaker isolation", () => { ); }); + it("does not let response.done override an earlier nested Responses failure", async () => { + const session = createSession(); + session.originalFormat = "response"; + const snapshot = { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: "failed-before-done-generation", + } as const; + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: true, + endpointId: 42, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 200, + bindingIntent: "create", + bindingSnapshot: snapshot, + requiresCompletionMarker: true, + }); + const body = + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "partial output", + })}\n\n` + + `event: response.failed\ndata: ${JSON.stringify({ + type: "response.failed", + response: { status: "failed", error: { message: "upstream failed" } }, + })}\n\n` + + `event: response.done\ndata: ${JSON.stringify({ type: "response.done" })}\n\n`; + + const clientResponse = await ProxyResponseHandler.dispatch( + session, + new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + ); + await clientResponse.text(); + await drainAsyncTasks(); + + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(mockRecordSuccess).not.toHaveBeenCalled(); + expect(mockRecordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_OPENAI_RESPONSE_FAILED: upstream failed" }) + ); + }); + it.each([ + { + label: "Claude data-only stop", + format: "claude" as const, + body: `data: ${JSON.stringify({ type: "message_stop" })}\n\n`, + }, { label: "OpenAI Responses", format: "response" as const, @@ -927,9 +1023,17 @@ describe("Endpoint circuit breaker isolation", () => { })}\n\n`, }, { - label: "Anthropic data-only", - format: "claude" as const, - body: `data: ${JSON.stringify({ type: "message_stop" })}\n\n`, + label: "OpenAI Responses data-only completed", + format: "response" as const, + body: `data: ${JSON.stringify({ + type: "response.completed", + response: { id: "resp_data_only_completed" }, + })}\n\n`, + }, + { + label: "OpenAI Responses done", + format: "response" as const, + body: `event: response.done\ndata: ${JSON.stringify({ type: "response.done" })}\n\n`, }, { label: "OpenAI Chat finish reason", @@ -950,6 +1054,13 @@ describe("Endpoint circuit breaker isolation", () => { candidates: [{ finishReason: "STOP" }], })}\n\n`, }, + { + label: "Gemini wrapped response", + format: "gemini" as const, + body: `data: ${JSON.stringify({ + response: { candidates: [{ finishReason: "STOP" }] }, + })}\n\n`, + }, { label: "Gemini CLI", format: "gemini-cli" as const, @@ -993,6 +1104,7 @@ describe("Endpoint circuit breaker isolation", () => { await drainAsyncTasks(); expect(SessionManager.compareAndSetSessionProvider).toHaveBeenCalledWith(snapshot, 1); + expect(mockRecordFailure).not.toHaveBeenCalled(); }); it("releases a create attempt ref when generation CAS loses", async () => { @@ -1243,7 +1355,7 @@ describe("Endpoint circuit breaker isolation", () => { } }); - it("touches the captured binding while a Discovery winner remains open", async () => { + it("touches the captured binding often enough for a long Discovery winner", async () => { vi.useFakeTimers(); try { const session = createSession(); @@ -1288,13 +1400,17 @@ describe("Endpoint circuit breaker isolation", () => { expect(SessionManager.compareAndSetSessionProvider).toHaveBeenCalledWith(snapshot, 1); expect(SessionManager.getSessionBindingSnapshot).not.toHaveBeenCalled(); - expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( + "fake-session", + 456, + "long-stream-owner" + ); } finally { vi.useRealTimers(); } }); - it("revokes Sticky writes when a binding heartbeat loses its generation", async () => { + it("does not revive a binding after an authority advances its generation", async () => { vi.useFakeTimers(); try { const session = createSession(); @@ -1302,7 +1418,7 @@ describe("Endpoint circuit breaker isolation", () => { sessionId: "fake-session", keyId: 456, providerId: 1, - generation: "generation-before-conflict", + generation: "generation-before-termination", } as const; setDeferredStreamingFinalization(session, { providerId: 1, @@ -1321,7 +1437,7 @@ describe("Endpoint circuit breaker isolation", () => { discoveryLease: { sessionId: "fake-session", keyId: 456, - ownerToken: "conflicted-stream-owner", + ownerToken: "terminated-stream-owner", ttlSeconds: 3_600, }, }); @@ -1349,7 +1465,11 @@ describe("Endpoint circuit breaker isolation", () => { expect(SessionManager.touchVersionedSessionBinding).toHaveBeenCalledTimes(2); expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); expect(SessionManager.getSessionBindingSnapshot).not.toHaveBeenCalled(); - expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledOnce(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( + "fake-session", + 456, + "terminated-stream-owner" + ); } finally { vi.useRealTimers(); } diff --git a/tests/unit/repository/system-config-degradation-ladder.test.ts b/tests/unit/repository/system-config-degradation-ladder.test.ts index e274a37ec..54020cdb6 100644 --- a/tests/unit/repository/system-config-degradation-ladder.test.ts +++ b/tests/unit/repository/system-config-degradation-ladder.test.ts @@ -7,6 +7,13 @@ import type { UpdateSystemSettingsInput } from "@/types/system-config"; // 近代新增列(最新在前),降级链按引入顺序逐层累计剥离。 const RECENT_COLUMNS = [ + "stickyTimeoutCooldownMs", + "racingTotalTimeoutMs", + "stickySlaMs", + "discoverySlaMs", + "maxDiscoveryRounds", + "discoveryConcurrency", + "discoveryEnabled", "enableGeminiFunctionIdRectifier", "enableThinkingEffortConflictRectifier", "billHedgeLosers", @@ -18,6 +25,13 @@ const RECENT_COLUMNS = [ // 全量字段集(44 列)。 const FULL_COLUMNS = [ + "discoveryEnabled", + "discoveryConcurrency", + "maxDiscoveryRounds", + "discoverySlaMs", + "stickySlaMs", + "racingTotalTimeoutMs", + "stickyTimeoutCooldownMs", "enableGeminiFunctionIdRectifier", "billHedgeLosers", "billNonSuccessfulRequests", @@ -167,7 +181,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const selectMock = vi.fn((selection: Record) => { selections.push(sortedKeys(selection)); callIndex += 1; - if (callIndex < 9) { + if (callIndex < 16) { return createRejectingSelectQuery({ code: "42703" }); } return createResolvingSelectQuery([ @@ -200,14 +214,14 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { const result = await getSystemSettings(); - expect(selectMock).toHaveBeenCalledTimes(9); - // 第 8 次(近代链末层)不含这两列;第 9 次(passThrough 世代)重新包含。 - expect(selections[7]).not.toContain("enableThinkingEffortConflictRectifier"); - expect(selections[7]).not.toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[7]).toContain("passThroughUpstreamErrorMessage"); - expect(selections[8]).toContain("enableThinkingEffortConflictRectifier"); - expect(selections[8]).toContain("allowNonConversationEndpointProviderFallback"); - expect(selections[8]).not.toContain("passThroughUpstreamErrorMessage"); + expect(selectMock).toHaveBeenCalledTimes(16); + // 第 15 次(近代链末层)不含这些新列;第 16 次(passThrough 世代)重新包含旧列。 + expect(selections[14]).not.toContain("enableThinkingEffortConflictRectifier"); + expect(selections[14]).not.toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[14]).toContain("passThroughUpstreamErrorMessage"); + expect(selections[15]).toContain("enableThinkingEffortConflictRectifier"); + expect(selections[15]).toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[15]).not.toContain("passThroughUpstreamErrorMessage"); // 世代字段集选出的真实值要透传,缺失列由 transformer 落默认值。 expect(result.siteTitle).toBe("Era Row"); @@ -285,7 +299,7 @@ describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { "system_settings 表列缺失,请执行数据库迁移以升级数据库结构。" ); - expect(updateMock).toHaveBeenCalledTimes(11); + expect(updateMock).toHaveBeenCalledTimes(18); const expectedReturningSequence = [ [...FULL_COLUMNS], diff --git a/tests/unit/repository/system-config-update-missing-columns.test.ts b/tests/unit/repository/system-config-update-missing-columns.test.ts index 1571b0f9c..611ab919d 100644 --- a/tests/unit/repository/system-config-update-missing-columns.test.ts +++ b/tests/unit/repository/system-config-update-missing-columns.test.ts @@ -299,7 +299,7 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { vi.setSystemTime(now); // 第一次 select(fullSelection) 因新列缺失而抛 42703; - // 第二次 select(selectionWithoutGeminiFunctionId) 命中——验证新列已加入降级链最外层。 + // 第二次 select(selectionWithoutDiscoveryColumn) 命中——验证新列已加入降级链最外层。 const selectMock = vi .fn() .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) @@ -339,11 +339,11 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { expect(result.siteTitle).toBe("Claude Code Hub"); expect(result.enableHttp2).toBe(true); - // 关键回归保护:第二次 select 必须恰好剥离了新列(最外层降级), - // 而非旧行为先剥离 enableThinkingEffortConflictRectifier。若新列未加入降级链最外层,下面两条断言会失败。 + // 关键回归保护:第二次 select 必须恰好剥离了最新 Discovery 列, + // 而非旧行为先剥离 enableGeminiFunctionIdRectifier。 const secondSelection = selectMock.mock.calls[1]?.[0] as Record; - expect(secondSelection).not.toHaveProperty("enableGeminiFunctionIdRectifier"); - expect(secondSelection).toHaveProperty("enableThinkingEffortConflictRectifier"); + expect(secondSelection).not.toHaveProperty("stickyTimeoutCooldownMs"); + expect(secondSelection).toHaveProperty("racingTotalTimeoutMs"); vi.useRealTimers(); }); diff --git a/tests/unit/validation/system-settings-discovery.test.ts b/tests/unit/validation/system-settings-discovery.test.ts new file mode 100644 index 000000000..9df378943 --- /dev/null +++ b/tests/unit/validation/system-settings-discovery.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; + +describe("UpdateSystemSettingsSchema Discovery settings", () => { + it("accepts the recommended defaults", () => { + const result = UpdateSystemSettingsSchema.parse({ + discoveryEnabled: false, + discoveryConcurrency: 2, + maxDiscoveryRounds: 2, + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + racingTotalTimeoutMs: 60_000, + stickyTimeoutCooldownMs: 300_000, + }); + expect(result.discoveryConcurrency).toBe(2); + }); + + it("requires at least one fallback slot in addition to the normal lane", () => { + const result = UpdateSystemSettingsSchema.safeParse({ discoveryConcurrency: 1 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("DISCOVERY_SETTINGS_INVALID"); + } + }); + + it("uses a stable error code when a Discovery value exceeds its supported range", () => { + const result = UpdateSystemSettingsSchema.safeParse({ stickyTimeoutCooldownMs: 86_400_001 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("DISCOVERY_SETTINGS_INVALID"); + } + }); + + it("rejects a total deadline shorter than the configured discovery window", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + maxDiscoveryRounds: 2, + racingTotalTimeoutMs: 30_000, + }) + ).toThrow("DISCOVERY_WINDOW_INVALID"); + }); + + it("accepts a total deadline exactly equal to the configured discovery window", () => { + expect(() => + UpdateSystemSettingsSchema.parse({ + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + maxDiscoveryRounds: 2, + racingTotalTimeoutMs: 40_000, + }) + ).not.toThrow(); + }); + + it("preserves an intentionally shorter Sticky SLA", () => { + const result = UpdateSystemSettingsSchema.parse({ + discoverySlaMs: 10_000, + stickySlaMs: 5_000, + maxDiscoveryRounds: 2, + racingTotalTimeoutMs: 25_000, + }); + expect(result.stickySlaMs).toBe(5_000); + }); + + it("allows partial updates so the server can merge them with stored settings", () => { + expect(UpdateSystemSettingsSchema.parse({ discoveryEnabled: true })).toEqual({ + discoveryEnabled: true, + }); + }); +});