From c17a75646d80e4577100cdf83cbc878dc95bb08e Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 14:25:58 -0400 Subject: [PATCH 01/32] fix(proxy): keep discovery disabled path compatible --- src/app/v1/_lib/proxy/forwarder.ts | 8 +++++++- tests/integration/proxy-hedge-lifecycle.test.ts | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 05cff7803..122e00d71 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3828,7 +3828,13 @@ export class ProxyForwarder { private static async shouldUseStreamingDiscovery(session: ProxySession): Promise { const settings = await getCachedSystemSettings(); - if (SessionManager.getVersionedBindingCapabilityState() !== "available") { + if (settings.discoveryEnabled !== true) { + return false; + } + if ( + typeof SessionManager.getVersionedBindingCapabilityState !== "function" || + SessionManager.getVersionedBindingCapabilityState() !== "available" + ) { return false; } const endpointPolicy = ProxyForwarder.getEndpointPolicy(session); diff --git a/tests/integration/proxy-hedge-lifecycle.test.ts b/tests/integration/proxy-hedge-lifecycle.test.ts index ec3e9a533..390727090 100644 --- a/tests/integration/proxy-hedge-lifecycle.test.ts +++ b/tests/integration/proxy-hedge-lifecycle.test.ts @@ -518,7 +518,8 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p outputTokens: 3, providerId: 2, statusCode: 200, - }) + }), + expect.any(Object) ); expect(state.updateMessageRequestDetailsIfUnfinalized).not.toHaveBeenCalled(); @@ -615,7 +616,8 @@ describe("proxy hedge transport/lifecycle integration (persistence and control-p expect(state.durableTerminal).toHaveBeenCalledOnce(); expect(state.durableTerminal).toHaveBeenCalledWith( MESSAGE.id, - expect.objectContaining({ statusCode: 502 }) + expect.objectContaining({ statusCode: 502 }), + expect.any(Object) ); expect(agents.release).toHaveBeenCalledOnce(); expect(agents.pool.getPoolStats().activeRequests).toBe(0); From 856a44ec8a7a98ba8f6e70c8b1d2520675cdb667 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 14:48:30 -0400 Subject: [PATCH 02/32] feat(settings): configure bounded streaming discovery --- docs/streaming-discovery.md | 61 + drizzle/0110_daffy_rawhide_kid.sql | 7 + drizzle/meta/0110_snapshot.json | 4691 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- messages/en/settings/config.json | 10 + messages/ja/settings/config.json | 10 + messages/ru/settings/config.json | 10 + messages/zh-CN/settings/config.json | 10 + messages/zh-TW/settings/config.json | 10 + src/actions/system-config.ts | 30 + .../_components/system-settings-form.tsx | 89 + src/app/[locale]/settings/config/page.tsx | 7 + src/app/api/admin/system-config/route.ts | 18 + src/drizzle/schema.ts | 9 + src/lib/api-client/v1/openapi-types.gen.ts | 42 + src/lib/api/v1/schemas/system-config.ts | 19 + src/lib/config/system-settings-cache.ts | 28 +- src/lib/validation/schemas.ts | 455 +- src/repository/_shared/transformers.ts | 7 + src/repository/system-config.ts | 78 + src/types/system-config.ts | 16 +- .../system-config-degradation-ladder.test.ts | 34 +- ...stem-config-update-missing-columns.test.ts | 10 +- .../system-settings-discovery.test.ts | 34 + 24 files changed, 5468 insertions(+), 226 deletions(-) create mode 100644 docs/streaming-discovery.md create mode 100644 drizzle/0110_daffy_rawhide_kid.sql create mode 100644 drizzle/meta/0110_snapshot.json create mode 100644 tests/unit/validation/system-settings-discovery.test.ts diff --git a/docs/streaming-discovery.md b/docs/streaming-discovery.md new file mode 100644 index 000000000..694d560d0 --- /dev/null +++ b/docs/streaming-discovery.md @@ -0,0 +1,61 @@ +# 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. + +## 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..f52f66675 --- /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; \ No newline at end of file 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 a838a044c..b4b89464d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -764,6 +764,13 @@ "when": 1783320834802, "tag": "0108_rich_onslaught", "breakpoints": true + }, + { + "idx": 110, + "version": "7", + "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..ad2fe21e8 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -118,6 +118,16 @@ "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.", "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..b7ca0000b 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -120,6 +120,16 @@ "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 ウィンドウより短くなっています。", "verboseProviderError": "詳細なプロバイダーエラー", "verboseProviderErrorDesc": "有効にすると、一部の上流障害タイプで `error.details` により詳細な診断情報(プロバイダー可用性の診断やサニタイズ済み上流断片など)を含める場合があります。", "verboseProviderErrorTooltip": "この設定を有効にすると、プロバイダー名、内部ルーティングの手掛かり、上流障害の理由などの診断情報が露出する可能性があります。クライアントに低レベルのトラブルシュート文脈を見せてもよい場合にのみ有効化してください。", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 05e3dc217..108a2e4f8 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -120,6 +120,16 @@ "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.", "verboseProviderError": "Подробные ошибки провайдеров", "verboseProviderErrorDesc": "При включении CCH может добавлять более подробную диагностику некоторых типов сбоев апстрима в `error.details` (например, диагностику доступности провайдеров или очищенные фрагменты ответа апстрима).", "verboseProviderErrorTooltip": "Может раскрывать названия провайдеров, внутренние подсказки маршрутизации, причины сбоев апстрима и другие диагностические детали. Включайте только если клиентам допустимо видеть низкоуровневый контекст отладки.", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 19ea20509..b3249e8dd 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -47,6 +47,16 @@ "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 窗口。", "verboseProviderError": "详细供应商错误信息", "verboseProviderErrorDesc": "开启后,CCH 会在某些上游失败类型下于 `error.details` 返回更详细的诊断信息(例如供应商可用性诊断或脱敏后的上游片段)。", "verboseProviderErrorTooltip": "该选项可能暴露供应商名称、内部路由线索、上游失败原因等诊断信息。仅建议在客户端可以查看底层排障上下文时开启。", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 590ff7a77..e510dd0c1 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -120,6 +120,16 @@ "billHedgeLosers": "對供應商競速輸家計費", "billHedgeLosersDesc": "開啟供應商競速後,競速落敗的供應商不再被直接掐斷,而是在後台保持連線、取回其 token 用量並計費,其費用會累加進本條請求的總花費。預設開啟。", "billHedgeLosersTooltip": "上游通常即使請求被我們主動取消也照常計費。保活競速輸家可以取回它們的 token 計數,使 CCH 的扣費與每個上游實際扣費保持一致。每個輸家的費用都會非同步累加到該請求的花費中。", + "discoveryEnabled": "啟用有界供應商 Discovery", + "discoveryEnabledDesc": "啟用後,冷啟動串流請求會在限定視窗內探測多個供應商,並且最多保留一個保底請求。預設關閉。", + "discoveryConcurrency": "Discovery 首輪並發數", + "maxDiscoveryRounds": "Discovery 最大輪數", + "discoverySlaMs": "探索 SLA(毫秒)", + "stickySlaMs": "Sticky 黏性 SLA(毫秒)", + "racingTotalTimeoutMs": "Discovery 總逾時(毫秒)", + "stickyTimeoutCooldownMs": "Sticky 逾時冷卻(毫秒)", + "discoveryWindowDesc": "總逾時必須不小於 Sticky SLA + 最大輪數 × Discovery SLA。Discovery 輸家會取消,不走舊 Hedge 的 drain 或輸家計費。", + "discoveryWindowInvalid": "Discovery 總逾時短於已設定的 Sticky 與 Discovery 視窗。", "verboseProviderError": "詳細供應商錯誤資訊", "verboseProviderErrorDesc": "開啟後,CCH 會在某些上游失敗類型下於 `error.details` 返回較詳細的診斷資訊(例如供應商可用性診斷或脫敏後的上游片段)。", "verboseProviderErrorTooltip": "此選項可能暴露供應商名稱、內部路由線索、上游失敗原因等診斷資訊。僅建議在客戶端可以查看底層排障上下文時開啟。", diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 5c42d12d0..39464d90b 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -64,6 +64,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 +117,22 @@ export async function saveSystemSettings(formData: { before = await getSystemSettings(); const validated = UpdateSystemSettingsSchema.parse(formData); + const effectiveDiscoveryWindow = { + discoverySlaMs: validated.discoverySlaMs ?? before.discoverySlaMs, + stickySlaMs: validated.stickySlaMs ?? before.stickySlaMs, + maxDiscoveryRounds: validated.maxDiscoveryRounds ?? before.maxDiscoveryRounds, + racingTotalTimeoutMs: validated.racingTotalTimeoutMs ?? before.racingTotalTimeoutMs, + }; + if ( + effectiveDiscoveryWindow.racingTotalTimeoutMs < + effectiveDiscoveryWindow.stickySlaMs + + effectiveDiscoveryWindow.maxDiscoveryRounds * effectiveDiscoveryWindow.discoverySlaMs + ) { + return { + ok: false, + error: "竞速总超时必须不小于 Sticky SLA + Discovery 轮数 × Discovery SLA", + }; + } const updated = await updateSystemSettings({ siteTitle: validated.siteTitle?.trim(), allowGlobalUsageView: validated.allowGlobalUsageView, @@ -118,6 +141,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, 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..26a4b7858 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -65,6 +65,13 @@ interface SystemSettingsFormProps { | "codexPriorityBillingSource" | "billNonSuccessfulRequests" | "billHedgeLosers" + | "discoveryEnabled" + | "discoveryConcurrency" + | "maxDiscoveryRounds" + | "discoverySlaMs" + | "stickySlaMs" + | "racingTotalTimeoutMs" + | "stickyTimeoutCooldownMs" | "timezone" | "verboseProviderError" | "passThroughUpstreamErrorMessage" @@ -130,6 +137,19 @@ 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 +250,11 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) return; } + if (racingTotalTimeoutMs < stickySlaMs + maxDiscoveryRounds * discoverySlaMs) { + toast.error(t("discoveryWindowInvalid")); + return; + } + const quotaDbRefreshIntervalSecondsToSave = clampQuotaDbRefreshIntervalSeconds( quotaDbRefreshIntervalSecondsStr ); @@ -311,6 +336,13 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) codexPriorityBillingSource, billNonSuccessfulRequests, billHedgeLosers, + discoveryEnabled, + discoveryConcurrency, + maxDiscoveryRounds, + discoverySlaMs, + stickySlaMs, + racingTotalTimeoutMs, + stickyTimeoutCooldownMs, timezone, verboseProviderError, passThroughUpstreamErrorMessage, @@ -353,6 +385,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 +675,56 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) /> + {/* Bounded Streaming Discovery */} +
+
+
+
+ +
+
+

{t("discoveryEnabled")}

+

{t("discoveryEnabledDesc")}

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

{t("discoveryWindowDesc")}

+
+ {/* 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..a5a925e73 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -56,9 +56,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: "竞速总超时必须不小于 Sticky SLA + Discovery 轮数 × Discovery SLA" }, + { status: 400 } + ); + } // 更新系统设置 const updated = await updateSystemSettings({ @@ -67,6 +78,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/drizzle/schema.ts b/src/drizzle/schema.ts index afba323ba..7534e86d2 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -780,6 +780,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..dd0ab0996 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 首字 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 首字 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 首字 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/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index 204c2db66..e52e21b17 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -98,6 +98,25 @@ 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() + .positive() + .describe("Maximum number of normal Discovery attempts in the initial batch."), + maxDiscoveryRounds: z.number().int().positive().describe("Maximum number of Discovery rounds."), + discoverySlaMs: z.number().int().positive().describe("首字 Discovery SLA in milliseconds."), + stickySlaMs: z.number().int().positive().describe("Sticky probe SLA in milliseconds."), + racingTotalTimeoutMs: z + .number() + .int() + .positive() + .describe("Total pre-winner Discovery deadline in milliseconds."), + stickyTimeoutCooldownMs: z + .number() + .int() + .positive() + .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 6649603f5..0dd733b75 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -54,6 +54,13 @@ const DEFAULT_SETTINGS: Pick< | "passThroughUpstreamErrorMessage" | "publicStatusWindowHours" | "publicStatusAggregationIntervalMinutes" + | "discoveryEnabled" + | "discoveryConcurrency" + | "maxDiscoveryRounds" + | "discoverySlaMs" + | "stickySlaMs" + | "racingTotalTimeoutMs" + | "stickyTimeoutCooldownMs" > = { enableHttp2: false, enableOpenaiResponsesWebsocket: true, @@ -84,6 +91,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, }; /** @@ -166,13 +180,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/schemas.ts b/src/lib/validation/schemas.ts index bd9d51c0b..f347fc7ba 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -946,205 +946,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 并发数必须是整数") + .min(1, "Discovery 并发数必须大于 0") + .max(32, "Discovery 并发数不能超过 32") + .optional(), + maxDiscoveryRounds: z.coerce + .number() + .int("Discovery 轮数必须是整数") + .min(1, "Discovery 轮数必须大于 0") + .max(32, "Discovery 轮数不能超过 32") + .optional(), + discoverySlaMs: z.coerce + .number() + .int("Discovery SLA 必须是整数毫秒") + .min(1, "Discovery SLA 必须大于 0") + .max(300_000, "Discovery SLA 不能超过 300000 毫秒") + .optional(), + stickySlaMs: z.coerce + .number() + .int("Sticky SLA 必须是整数毫秒") + .min(1, "Sticky SLA 必须大于 0") + .max(600_000, "Sticky SLA 不能超过 600000 毫秒") + .optional(), + racingTotalTimeoutMs: z.coerce + .number() + .int("竞速总超时必须是整数毫秒") + .min(1, "竞速总超时必须大于 0") + .max(3_600_000, "竞速总超时不能超过 3600000 毫秒") + .optional(), + stickyTimeoutCooldownMs: z.coerce + .number() + .int("Sticky 冷却时间必须是整数毫秒") + .min(1, "Sticky 冷却时间必须大于 0") + .max(86_400_000, "Sticky 冷却时间不能超过 86400000 毫秒") + .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: "竞速总超时必须不小于 Sticky SLA + Discovery 轮数 × Discovery SLA", + }); + } + } + }); // 导出类型推断 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/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..f6b7df030 --- /dev/null +++ b/tests/unit/validation/system-settings-discovery.test.ts @@ -0,0 +1,34 @@ +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("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("竞速总超时"); + }); + + it("allows partial updates so the server can merge them with stored settings", () => { + expect(UpdateSystemSettingsSchema.parse({ discoveryEnabled: true })).toEqual({ + discoveryEnabled: true, + }); + }); +}); From 722900e3aebb3ad36b604a799eae45808d186754 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 14:54:02 -0400 Subject: [PATCH 03/32] fix(proxy): close discovery coordinator lifecycle gaps --- .../v1/_lib/proxy/discovery-coordinator.ts | 45 ++++- src/app/v1/_lib/proxy/discovery-validity.ts | 51 +++++- src/app/v1/_lib/proxy/forwarder.ts | 154 +++++++++++++----- .../unit/proxy/discovery-coordinator.test.ts | 22 ++- tests/unit/proxy/discovery-validity.test.ts | 14 ++ 5 files changed, 227 insertions(+), 59 deletions(-) diff --git a/src/app/v1/_lib/proxy/discovery-coordinator.ts b/src/app/v1/_lib/proxy/discovery-coordinator.ts index 44d9de404..4d4462b2c 100644 --- a/src/app/v1/_lib/proxy/discovery-coordinator.ts +++ b/src/app/v1/_lib/proxy/discovery-coordinator.ts @@ -29,8 +29,13 @@ export type DiscoveryAttempt = { export type DiscoveryAction = | { type: "commit_normal"; attemptId: string } | { type: "promote_fallback"; attemptId: string } - | { type: "cancel"; attemptIds: string[] } - | { type: "launch"; slots: number } + | { type: "cancel"; attemptIds: string[]; promoteAttemptId?: string } + | { + type: "launch"; + slots: number; + cancelAttemptIds?: string[]; + promoteAttemptId?: string; + } | { type: "none" } | { type: "terminal_failure" }; @@ -63,6 +68,7 @@ export class DiscoveryCoordinator { beginRound(): { requestEpoch: number; roundEpoch: number; round: number } { this.roundEpoch += 1; + if (!this.isTerminal) this.state = "DISCOVERY_RACING"; return { ...this.epochs, round: this.round }; } @@ -102,6 +108,17 @@ export class DiscoveryCoordinator { const attempt = this.attempts.get(id); if (!attempt?.pending) return { type: "none" }; attempt.ready = true; + if (attempt.kind === "fallback") { + const pendingNormal = Array.from(this.attempts.values()).some( + (candidate) => candidate.pending && candidate.kind === "normal" + ); + if (!pendingNormal) { + attempt.pending = false; + this.state = "FALLBACK_ACTIVE"; + return { type: "promote_fallback", attemptId: attempt.id }; + } + return { type: "none" }; + } return this.chooseReadyNormal(); } @@ -168,14 +185,19 @@ export class DiscoveryCoordinator { .filter((attempt) => attempt.pending && attempt.kind === "normal") .sort(compareAttempts); if (currentFallback && pendingNormal.length > 0) { + const cancelAttemptIds = pendingNormal.map((attempt) => attempt.id); for (const attempt of pendingNormal) attempt.pending = false; if (this.round < this.maxRounds) { this.round += 1; this.roundEpoch += 1; this.state = "DISCOVERY_RACING"; - return { type: "launch", slots: Math.max(1, this.concurrency - 1) }; + return { + type: "launch", + slots: Math.max(1, this.concurrency - 1), + cancelAttemptIds, + }; } - return { type: "none" }; + return { type: "cancel", attemptIds: cancelAttemptIds }; } if (pendingNormal.length === 0) { if (currentFallback) { @@ -191,11 +213,18 @@ export class DiscoveryCoordinator { const losers = pendingNormal.slice(1).map((attempt) => attempt.id); for (const id of losers) this.attempts.get(id)!.pending = false; - if (currentFallback) { - currentFallback.pending = true; - return { type: "cancel", attemptIds: losers }; + if (this.round < this.maxRounds) { + this.round += 1; + this.roundEpoch += 1; + this.state = "DISCOVERY_RACING"; + return { + type: "launch", + slots: Math.max(1, this.concurrency - 1), + cancelAttemptIds: losers, + promoteAttemptId: fallback.id, + }; } - return { type: "cancel", attemptIds: losers }; + return { type: "cancel", attemptIds: losers, promoteAttemptId: fallback.id }; } onDeadline(): DiscoveryAction { diff --git a/src/app/v1/_lib/proxy/discovery-validity.ts b/src/app/v1/_lib/proxy/discovery-validity.ts index 29ad6a960..d4ece4eff 100644 --- a/src/app/v1/_lib/proxy/discovery-validity.ts +++ b/src/app/v1/_lib/proxy/discovery-validity.ts @@ -137,11 +137,58 @@ export class DiscoveryValidityParser { push(chunk: Uint8Array | string): DiscoveryValidity { this.buffered += typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true }); - const result = classifyDiscoveryChunk(this.buffered, this.protocol); + + // SSE streams are line framed. Consume each completed line once instead + // of reparsing the complete prefix on every chunk (which is quadratic on + // long streams). Keep only the unfinished line for the next push. + if (this.buffered.includes("\n")) { + const lines = this.buffered.split(/\r?\n/); + this.buffered = lines.pop() ?? ""; + for (const line of lines) this.consumeLine(line); + } + + // Some providers return one raw JSON object without an SSE newline. Parse + // it only when the complete object is available; incomplete JSON remains + // buffered and is not repeatedly scanned as a protocol event. + const tail = this.buffered.trim(); + if (tail) { + const candidate = tail.startsWith("data:") ? tail.slice(5).trim() : tail; + if (candidate === "[DONE]") { + this._terminal = true; + this.buffered = ""; + } else if (candidate.startsWith("{") || candidate.startsWith("[")) { + try { + const value = JSON.parse(candidate) as unknown; + this.consumeValue(value); + this.buffered = ""; + } catch { + // Keep incomplete raw JSON until the next chunk completes it. + } + } + } + + return { ready: this._ready && !this._error, terminal: this._terminal, error: this._error }; + } + + private consumeLine(line: string): void { + const candidate = line.startsWith("data:") ? line.slice(5).trim() : line.trim(); + if (!candidate || candidate.startsWith(":")) return; + if (candidate === "[DONE]") { + this._terminal = true; + return; + } + try { + this.consumeValue(JSON.parse(candidate) as unknown); + } catch { + // Ignore comments and incomplete/non-JSON protocol lines. + } + } + + private consumeValue(value: unknown): void { + const result = classifyJson(value, this.protocol); this._ready ||= result.ready; this._terminal ||= result.terminal; this._error ||= result.error; - return { ready: this._ready && !this._error, terminal: this._terminal, error: this._error }; } get ready(): boolean { diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 122e00d71..18182a2e1 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -63,7 +63,7 @@ import { buildProxyUrl } from "../url"; import { rectifyBillingHeader } from "./billing-header-rectifier"; import { bindClientAbortListener } from "./client-abort-listener"; import { deriveClientSafeUpstreamErrorMessage } from "./client-error-message"; -import { DiscoveryCoordinator } from "./discovery-coordinator"; +import { type DiscoveryAction, DiscoveryCoordinator } from "./discovery-coordinator"; import { type DiscoveryProtocol, DiscoveryValidityParser } from "./discovery-validity"; import { isStandardProxyEndpointPath } from "./endpoint-family-catalog"; import { resolveEndpointPolicy, shouldEnforceStrictEndpointPoolPolicy } from "./endpoint-policy"; @@ -4875,6 +4875,7 @@ export class ProxyForwarder { pending: boolean; ready: boolean; round: number; + readerTransferred: boolean; } >(); const launched = new Set(); @@ -4889,6 +4890,7 @@ export class ProxyForwarder { let totalTimer: NodeJS.Timeout | null = null; let roundTimer: NodeJS.Timeout | null = null; let stickyTimer: NodeJS.Timeout | null = null; + let executeCoordinatorAction: (action: DiscoveryAction) => Promise = async () => {}; let resolveResult: ((result: { response?: Response; error?: Error }) => void) | null = null; const resultPromise = new Promise<{ response?: Response; error?: Error }>((resolve) => { resolveResult = resolve; @@ -4904,6 +4906,7 @@ export class ProxyForwarder { }; const cancelAttempt = (attempt: (typeof winner & { id: string }) | null, reason: string) => { + if (attempt?.readerTransferred) return; if (!attempt?.pending) return; attempt.pending = false; try { @@ -4911,7 +4914,12 @@ export class ProxyForwarder { } catch { /* abort is best effort */ } - void attempt.reader?.cancel(reason).catch(() => undefined); + try { + const cancelPromise = attempt.reader?.cancel(reason); + cancelPromise?.catch(() => undefined); + } catch (error) { + logger.debug("[Discovery] Reader cancel failed", { reason, error }); + } try { attempt.releaseAgent?.(); } catch { @@ -4933,8 +4941,18 @@ export class ProxyForwarder { if (roundTimer) clearTimeout(roundTimer); if (stickyTimer) clearTimeout(stickyTimer); cancelLosers(); - const attempted = new Set(launched); - await ProxyForwarder.clearSessionProviderBindings(session, attempted); + if (bindingSnapshot) { + if (bindingSnapshot.providerId != null) { + await SessionManager.clearVersionedSessionProvider( + bindingSnapshot, + bindingSnapshot.providerId, + 0 + ); + } + } else { + const attempted = new Set(launched); + await ProxyForwarder.clearSessionProviderBindings(session, attempted); + } resolveResult?.({ error }); }; @@ -4943,6 +4961,9 @@ export class ProxyForwarder { committed = true; winner = attempt; attempt.pending = false; + // From this point ResponseHandler owns the reader and agent release. + // No coordinator/timer path may cancel or release this attempt again. + attempt.readerTransferred = true; if (totalTimer) clearTimeout(totalTimer); if (roundTimer) clearTimeout(roundTimer); if (stickyTimer) clearTimeout(stickyTimer); @@ -5044,6 +5065,7 @@ export class ProxyForwarder { pending: true, ready: false, round: currentRound, + readerTransferred: false, provider, session: attemptSession, baseUrl: endpoint.baseUrl, @@ -5080,6 +5102,7 @@ export class ProxyForwarder { pending: boolean; ready: boolean; round: number; + readerTransferred: boolean; }; attempts.set(id, attempt); coordinator.addAttempt({ @@ -5138,7 +5161,7 @@ export class ProxyForwarder { .catch(async (error) => { if (committed || settled || !attempt.pending) return; attempt.pending = false; - coordinator.markFailed(id); + const failureAction = coordinator.markFailed(id); lastError = error instanceof Error ? error : new Error(String(error)); lastErrorCategory = await categorizeErrorAsync(lastError); session.addProviderToChain(provider, { @@ -5156,8 +5179,17 @@ export class ProxyForwarder { } attempt.releaseAgent?.(); releaseProviderRef(provider.id); - const replacement = await chooseCandidate(); - if (replacement && !committed && !settled) await launch(replacement, "normal"); + const actionOwnsNextStep = + failureAction.type === "promote_fallback" || + failureAction.type === "launch" || + failureAction.type === "terminal_failure"; + if (actionOwnsNextStep) { + await executeCoordinatorAction(failureAction); + } + if (!actionOwnsNextStep && !committed && !settled) { + const replacement = await chooseCandidate(); + if (replacement) await launch(replacement, "normal"); + } if ( Array.from(attempts.values()).every((candidate) => !candidate.pending) && noMoreCandidates @@ -5166,6 +5198,12 @@ export class ProxyForwarder { ProxyForwarder.resolveHedgeTerminalError(lastError, lastErrorCategory) ); } + }) + .catch((error) => { + logger.warn("[Discovery] Attempt completion handler failed", { + providerId: provider.id, + error, + }); }); }; @@ -5184,60 +5222,75 @@ export class ProxyForwarder { } } if (!committed && !settled) { - roundTimer = setTimeout(() => void onBoundary(), discoverySlaMs); + roundTimer = setTimeout(() => { + void onBoundary().catch((error) => + logger.warn("[Discovery] Round boundary failed", { error }) + ); + }, discoverySlaMs); } }; - const onBoundary = async () => { + executeCoordinatorAction = async (action) => { if (settled || committed) return; - const fallback = Array.from(attempts.values()).find( - (attempt) => attempt.pending && attempt.kind === "fallback" - ); - const pendingNormals = Array.from(attempts.values()) - .filter((attempt) => attempt.pending && attempt.kind === "normal") - .sort( - (a, b) => - (a.provider.priority || 0) - (b.provider.priority || 0) || a.sequence - b.sequence - ); - const readyNormal = pendingNormals.find((attempt) => attempt.ready); - if (readyNormal) { - await commit(readyNormal); + if (action.type === "cancel" || action.type === "launch") { + const cancelIds = + action.type === "cancel" ? action.attemptIds : (action.cancelAttemptIds ?? []); + for (const id of cancelIds) { + const attempt = attempts.get(id); + // 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_round_boundary"); + } + if (action.promoteAttemptId) { + const fallback = attempts.get(action.promoteAttemptId); + if (fallback) fallback.kind = "fallback"; + if (action.type === "cancel" && currentRound < maxRounds) { + await launchNextRound(); + } + } + } + if (action.type === "commit_normal" || action.type === "promote_fallback") { + const attempt = attempts.get(action.attemptId); + if (attempt) await commit(attempt); return; } - // The fallback is held during the SLA window, but at the round - // boundary it may take over when no normal result is ready. - if (fallback?.ready) { - await commit(fallback); + if (action.type === "launch") { + await launchNextRound(); return; } - if (pendingNormals[0]) { - if (fallback) { - for (const loser of pendingNormals) cancelAttempt(loser, "discovery_round_boundary"); - if (currentRound < maxRounds) await launchNextRound(); - return; - } - pendingNormals[0].kind = "fallback"; - for (const loser of pendingNormals.slice(1)) - cancelAttempt(loser, "discovery_round_boundary"); - if (currentRound < maxRounds) await launchNextRound(); + if (action.type === "terminal_failure") { + await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); return; } - if (currentRound < maxRounds) await launchNextRound(); - else await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); + if (action.type === "none") { + const fallbackPending = Array.from(attempts.values()).some( + (attempt) => attempt.pending && attempt.kind === "fallback" + ); + if (fallbackPending && currentRound < maxRounds) { + await launchNextRound(); + } + } + }; + + const onBoundary = async () => { + if (settled || committed) return; + await executeCoordinatorAction(coordinator.onRoundBoundary()); }; const cleanupAbort = bindClientAbortListener(session.clientAbortSignal, () => { if (settled || committed) return; - void settleFailure(new ProxyError("Request aborted by client", 499, undefined, true)); + void settleFailure(new ProxyError("Request aborted by client", 499, undefined, true)).catch( + (error) => logger.warn("[Discovery] Client abort cleanup failed", { error }) + ); }); totalTimer = setTimeout(() => { if (settled || committed) return; - const fallback = Array.from(attempts.values()).find( - (attempt) => attempt.pending && attempt.kind === "fallback" && attempt.ready + void executeCoordinatorAction(coordinator.onDeadline()).catch((error) => + logger.warn("[Discovery] Deadline action failed", { error }) ); - if (fallback) void commit(fallback); - else void settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); }, totalTimeoutMs); try { @@ -5267,8 +5320,15 @@ export class ProxyForwarder { logger.debug("[Discovery] Failed to clear timed-out Sticky", { error }) ); } - if (currentRound < maxRounds) void launchNextRound(); - else void onBoundary(); + if (currentRound < maxRounds) { + void launchNextRound().catch((error) => + logger.warn("[Discovery] Sticky round launch failed", { error }) + ); + } else { + void onBoundary().catch((error) => + logger.warn("[Discovery] Sticky boundary failed", { error }) + ); + } } }, stickySlaMs); } else { @@ -5285,7 +5345,11 @@ export class ProxyForwarder { noMoreCandidates = true; } } - roundTimer = setTimeout(() => void onBoundary(), discoverySlaMs); + roundTimer = setTimeout(() => { + void onBoundary().catch((error) => + logger.warn("[Discovery] Round boundary failed", { error }) + ); + }, discoverySlaMs); } const result = await resultPromise; if (result.error) throw result.error; diff --git a/tests/unit/proxy/discovery-coordinator.test.ts b/tests/unit/proxy/discovery-coordinator.test.ts index 70b09b6d0..35585c473 100644 --- a/tests/unit/proxy/discovery-coordinator.test.ts +++ b/tests/unit/proxy/discovery-coordinator.test.ts @@ -27,7 +27,11 @@ describe("DiscoveryCoordinator", () => { coordinator.addAttempt(attempt("a", 1)); coordinator.addAttempt(attempt("b", 2)); const action = coordinator.onRoundBoundary(); - expect(action.type).toBe("cancel"); + expect(action).toMatchObject({ + type: "launch", + promoteAttemptId: "a", + cancelAttemptIds: ["b"], + }); expect(coordinator.snapshot.find((item) => item.id === "a")?.kind).toBe("fallback"); expect(coordinator.snapshot.filter((item) => item.pending)).toHaveLength(1); }); @@ -45,9 +49,7 @@ describe("DiscoveryCoordinator", () => { it("promotes a ready fallback at the round boundary when no normal is ready", () => { const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 2 }); coordinator.addAttempt(attempt("a", 1, "fallback")); - coordinator.addAttempt(attempt("b", 1, "normal")); - expect(coordinator.markReady("a")).toEqual({ type: "none" }); - expect(coordinator.onRoundBoundary()).toEqual({ type: "promote_fallback", attemptId: "a" }); + expect(coordinator.markReady("a")).toEqual({ type: "promote_fallback", attemptId: "a" }); expect(coordinator.snapshot.find((item) => item.id === "a")?.kind).toBe("fallback"); }); @@ -60,4 +62,16 @@ describe("DiscoveryCoordinator", () => { coordinator.markReady("c"); expect(coordinator.onRoundBoundary()).toEqual({ type: "commit_normal", attemptId: "c" }); }); + + it("reports normal attempts cancelled when retaining an existing fallback", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 3 }); + coordinator.addAttempt(attempt("fallback", 1, "fallback")); + coordinator.addAttempt(attempt("normal", 2)); + const action = coordinator.onRoundBoundary(); + expect(action).toEqual({ + type: "launch", + slots: 1, + cancelAttemptIds: ["normal"], + }); + }); }); diff --git a/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index 4d5e6cae2..83feb4f8e 100644 --- a/tests/unit/proxy/discovery-validity.test.ts +++ b/tests/unit/proxy/discovery-validity.test.ts @@ -60,4 +60,18 @@ describe("discovery validity", () => { ).ready ).toBe(true); }); + + it("consumes split SSE lines incrementally without waiting for the full stream", () => { + const parser = new DiscoveryValidityParser("openai-chat"); + expect(parser.push('data: {"choices":[{"delta":{"content":"hel')).toEqual({ + ready: false, + terminal: false, + error: false, + }); + expect(parser.push('lo"}}]}\n\n')).toEqual({ + ready: true, + terminal: false, + error: false, + }); + }); }); From 3edd52b09721f9f579de313ca8e149465864b8c4 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 14:56:20 -0400 Subject: [PATCH 04/32] fix(proxy): guard optional discovery request message --- src/app/v1/_lib/proxy/forwarder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 18182a2e1..753e7d77f 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3848,7 +3848,7 @@ export class ProxyForwarder { settings.discoveryEnabled === true && endpointPolicy.allowRetry && endpointPolicy.allowProviderSwitch && - message.stream === true && + message?.stream === true && !endpointPolicy.bypassForwarderPreprocessing && protocol !== "unknown" && routing.routingMode !== "lease_conflict_single" && From fcaf248cc1d1dadc19a8fdf0dc9d3f0713ed3130 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 14:58:09 -0400 Subject: [PATCH 05/32] fix(proxy): handle replacement launch failures --- src/app/v1/_lib/proxy/forwarder.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 753e7d77f..a96f93e90 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -5300,8 +5300,19 @@ export class ProxyForwarder { } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); const replacement = await chooseCandidate(); - if (replacement) await launch(replacement, "normal"); - else await settleFailure(ProxyForwarder.resolveHedgeTerminalError(lastError, null)); + if (replacement) { + try { + await launch(replacement, "normal"); + } catch (replacementError) { + lastError = + replacementError instanceof Error + ? replacementError + : new Error(String(replacementError)); + await settleFailure(ProxyForwarder.resolveHedgeTerminalError(lastError, null)); + } + } else { + await settleFailure(ProxyForwarder.resolveHedgeTerminalError(lastError, null)); + } } const initial = hasSticky ? concurrency - 1 : Math.max(0, concurrency - 1); if (hasSticky) { From 00de8abd8ddf907f66df1b033db74414e469fb5d Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 15:30:00 -0400 Subject: [PATCH 06/32] fix(discovery): close coordinator and configuration lifecycle gaps --- messages/zh-TW/settings/config.json | 4 +- src/actions/system-config.ts | 25 ++- .../_components/system-settings-form.tsx | 53 +++-- src/app/api/admin/system-config/route.ts | 2 +- .../v1/_lib/proxy/discovery-coordinator.ts | 34 +-- src/app/v1/_lib/proxy/forwarder.ts | 196 ++++++++++++------ src/lib/api-client/v1/openapi-types.gen.ts | 6 +- src/lib/api/v1/schemas/system-config.ts | 2 +- .../unit/proxy/discovery-coordinator.test.ts | 43 ++++ 9 files changed, 264 insertions(+), 101 deletions(-) diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index e510dd0c1..3e4a4b2dc 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -124,8 +124,8 @@ "discoveryEnabledDesc": "啟用後,冷啟動串流請求會在限定視窗內探測多個供應商,並且最多保留一個保底請求。預設關閉。", "discoveryConcurrency": "Discovery 首輪並發數", "maxDiscoveryRounds": "Discovery 最大輪數", - "discoverySlaMs": "探索 SLA(毫秒)", - "stickySlaMs": "Sticky 黏性 SLA(毫秒)", + "discoverySlaMs": "Discovery SLA(毫秒)", + "stickySlaMs": "Sticky SLA(毫秒)", "racingTotalTimeoutMs": "Discovery 總逾時(毫秒)", "stickyTimeoutCooldownMs": "Sticky 逾時冷卻(毫秒)", "discoveryWindowDesc": "總逾時必須不小於 Sticky SLA + 最大輪數 × Discovery SLA。Discovery 輸家會取消,不走舊 Hedge 的 drain 或輸家計費。", diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 39464d90b..2bbf59be8 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -25,6 +25,13 @@ import type { } from "@/types/system-config"; import type { ActionResult } from "./types"; +const DEFAULT_DISCOVERY_WINDOW = { + discoverySlaMs: 10_000, + stickySlaMs: 20_000, + maxDiscoveryRounds: 2, + racingTotalTimeoutMs: 60_000, +} as const; + export async function fetchSystemSettings(): Promise> { try { const session = await getSession(); @@ -118,10 +125,20 @@ export async function saveSystemSettings(formData: { before = await getSystemSettings(); const validated = UpdateSystemSettingsSchema.parse(formData); const effectiveDiscoveryWindow = { - discoverySlaMs: validated.discoverySlaMs ?? before.discoverySlaMs, - stickySlaMs: validated.stickySlaMs ?? before.stickySlaMs, - maxDiscoveryRounds: validated.maxDiscoveryRounds ?? before.maxDiscoveryRounds, - racingTotalTimeoutMs: validated.racingTotalTimeoutMs ?? before.racingTotalTimeoutMs, + discoverySlaMs: + validated.discoverySlaMs ?? + before?.discoverySlaMs ?? + DEFAULT_DISCOVERY_WINDOW.discoverySlaMs, + stickySlaMs: + validated.stickySlaMs ?? before?.stickySlaMs ?? DEFAULT_DISCOVERY_WINDOW.stickySlaMs, + maxDiscoveryRounds: + validated.maxDiscoveryRounds ?? + before?.maxDiscoveryRounds ?? + DEFAULT_DISCOVERY_WINDOW.maxDiscoveryRounds, + racingTotalTimeoutMs: + validated.racingTotalTimeoutMs ?? + before?.racingTotalTimeoutMs ?? + DEFAULT_DISCOVERY_WINDOW.racingTotalTimeoutMs, }; if ( effectiveDiscoveryWindow.racingTotalTimeoutMs < 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 26a4b7858..ae3307d22 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -114,6 +114,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(); @@ -138,16 +139,20 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) ); const [billHedgeLosers, setBillHedgeLosers] = useState(initialSettings.billHedgeLosers); const [discoveryEnabled, setDiscoveryEnabled] = useState(initialSettings.discoveryEnabled); - const [discoveryConcurrency, setDiscoveryConcurrency] = useState( + 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( + 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( + const [stickyTimeoutCooldownMs, setStickyTimeoutCooldownMs] = useState( initialSettings.stickyTimeoutCooldownMs ); const [timezone, setTimezone] = useState(initialSettings.timezone); @@ -250,7 +255,27 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) return; } - if (racingTotalTimeoutMs < stickySlaMs + maxDiscoveryRounds * discoverySlaMs) { + const discoveryConfig = { + discoveryConcurrency: Number(discoveryConcurrency), + maxDiscoveryRounds: Number(maxDiscoveryRounds), + discoverySlaMs: Number(discoverySlaMs), + stickySlaMs: Number(stickySlaMs), + racingTotalTimeoutMs: Number(racingTotalTimeoutMs), + stickyTimeoutCooldownMs: Number(stickyTimeoutCooldownMs), + }; + if ( + discoveryEnabled && + Object.values(discoveryConfig).some((value) => !Number.isSafeInteger(value) || value < 1) + ) { + toast.error(t("discoveryWindowInvalid")); + return; + } + if ( + discoveryEnabled && + discoveryConfig.racingTotalTimeoutMs < + discoveryConfig.stickySlaMs + + discoveryConfig.maxDiscoveryRounds * discoveryConfig.discoverySlaMs + ) { toast.error(t("discoveryWindowInvalid")); return; } @@ -337,12 +362,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) billNonSuccessfulRequests, billHedgeLosers, discoveryEnabled, - discoveryConcurrency, - maxDiscoveryRounds, - discoverySlaMs, - stickySlaMs, - racingTotalTimeoutMs, - stickyTimeoutCooldownMs, + ...(discoveryEnabled ? discoveryConfig : {}), timezone, verboseProviderError, passThroughUpstreamErrorMessage, @@ -714,8 +734,11 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) id={`discovery-${key}`} type="number" min={min} - value={value} - onChange={(event) => setter(Number(event.target.value))} + required={discoveryEnabled} + value={value === 0 ? "" : value} + onChange={(event) => + setter(event.target.value === "" ? "" : Number(event.target.value)) + } disabled={isPending || !discoveryEnabled} className={inputClassName} /> diff --git a/src/app/api/admin/system-config/route.ts b/src/app/api/admin/system-config/route.ts index a5a925e73..06a7f091d 100644 --- a/src/app/api/admin/system-config/route.ts +++ b/src/app/api/admin/system-config/route.ts @@ -66,7 +66,7 @@ export async function POST(req: Request) { const racingTotalTimeoutMs = validated.racingTotalTimeoutMs ?? current.racingTotalTimeoutMs; if (racingTotalTimeoutMs < stickySlaMs + maxDiscoveryRounds * discoverySlaMs) { return Response.json( - { error: "竞速总超时必须不小于 Sticky SLA + Discovery 轮数 × Discovery SLA" }, + { error: "discoveryWindowInvalid", errorCode: "discoveryWindowInvalid" }, { status: 400 } ); } diff --git a/src/app/v1/_lib/proxy/discovery-coordinator.ts b/src/app/v1/_lib/proxy/discovery-coordinator.ts index 4d4462b2c..92bb0880c 100644 --- a/src/app/v1/_lib/proxy/discovery-coordinator.ts +++ b/src/app/v1/_lib/proxy/discovery-coordinator.ts @@ -67,6 +67,10 @@ export class DiscoveryCoordinator { } beginRound(): { requestEpoch: number; roundEpoch: number; round: number } { + if (this.isTerminal || this.round >= this.maxRounds) { + return { ...this.epochs, round: this.round }; + } + this.round += 1; this.roundEpoch += 1; if (!this.isTerminal) this.state = "DISCOVERY_RACING"; return { ...this.epochs, round: this.round }; @@ -83,7 +87,11 @@ export class DiscoveryCoordinator { } get isTerminal(): boolean { - return this.state === "WINNER_COMMITTED" || this.state === "TERMINAL_FAILED"; + return ( + this.state === "WINNER_COMMITTED" || + this.state === "FALLBACK_ACTIVE" || + this.state === "TERMINAL_FAILED" + ); } get activeAttempts(): DiscoveryAttempt[] { @@ -132,6 +140,8 @@ export class DiscoveryCoordinator { if (!attempt) return { type: "none" }; attempt.pending = false; attempt.ready = false; + const readyAction = this.chooseReadyNormal(); + if (readyAction.type !== "none") return readyAction; return this.afterAttemptState(); } @@ -152,8 +162,7 @@ export class DiscoveryCoordinator { ); if (higherTierPending) return { type: "none" }; } - const sameTier = readyNormal.filter((attempt) => attempt.priority === bestPriority); - const winner = sameTier[0]; + const winner = readyNormal[0]; this.state = "WINNER_COMMITTED"; winner.pending = false; return { @@ -188,12 +197,11 @@ export class DiscoveryCoordinator { const cancelAttemptIds = pendingNormal.map((attempt) => attempt.id); for (const attempt of pendingNormal) attempt.pending = false; if (this.round < this.maxRounds) { - this.round += 1; - this.roundEpoch += 1; + this.beginRound(); this.state = "DISCOVERY_RACING"; return { type: "launch", - slots: Math.max(1, this.concurrency - 1), + slots: Math.max(0, this.concurrency - 1), cancelAttemptIds, }; } @@ -214,12 +222,11 @@ export class DiscoveryCoordinator { for (const id of losers) this.attempts.get(id)!.pending = false; if (this.round < this.maxRounds) { - this.round += 1; - this.roundEpoch += 1; + this.beginRound(); this.state = "DISCOVERY_RACING"; return { type: "launch", - slots: Math.max(1, this.concurrency - 1), + slots: Math.max(0, this.concurrency - 1), cancelAttemptIds: losers, promoteAttemptId: fallback.id, }; @@ -229,6 +236,8 @@ export class DiscoveryCoordinator { onDeadline(): DiscoveryAction { if (this.isTerminal) return { type: "none" }; + const readyNormal = this.chooseReadyNormal(true); + if (readyNormal.type === "commit_normal") return readyNormal; const fallback = Array.from(this.attempts.values()).find( (attempt) => attempt.pending && attempt.kind === "fallback" && attempt.ready ); @@ -245,7 +254,7 @@ export class DiscoveryCoordinator { const attempt = this.attempts.get(id); if (!attempt || this.isTerminal) return { type: "none" }; attempt.pending = false; - this.state = "WINNER_COMMITTED"; + this.state = attempt.kind === "fallback" ? "FALLBACK_ACTIVE" : "WINNER_COMMITTED"; return { type: attempt.kind === "fallback" ? "promote_fallback" : "commit_normal", attemptId: id, @@ -276,9 +285,8 @@ export class DiscoveryCoordinator { this.state = "TERMINAL_FAILED"; return { type: "terminal_failure" }; } - this.round += 1; - this.roundEpoch += 1; + this.beginRound(); this.state = "DISCOVERY_RACING"; - return { type: "launch", slots: Math.max(1, this.concurrency - 1) }; + return { type: "launch", slots: this.concurrency }; } } diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index a96f93e90..a90a6c622 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3845,7 +3845,6 @@ export class ProxyForwarder { disableStreamingHedge?: boolean; }; return ( - settings.discoveryEnabled === true && endpointPolicy.allowRetry && endpointPolicy.allowProviderSwitch && message?.stream === true && @@ -5035,6 +5034,14 @@ export class ProxyForwarder { const launch = async (provider: Provider, kind: "normal" | "fallback"): Promise => { if (settled || committed || launched.has(provider.id)) return; launched.add(provider.id); + let providerSessionRefTracked = false; + const rollbackLaunch = () => { + launched.delete(provider.id); + if (providerSessionRefTracked) { + releaseProviderRef(provider.id); + providerSessionRefTracked = false; + } + }; if (provider.id !== initialProvider.id && session.sessionId) { const limit = provider.limitConcurrentSessions || 0; const check = await RateLimitService.checkAndTrackProviderSession( @@ -5046,14 +5053,29 @@ export class ProxyForwarder { launched.delete(provider.id); throw new ProxyError(check.reason || "Provider concurrent limit reached", 503); } - if (check.referenced) session.recordProviderSessionRef(provider.id); + if (check.referenced) { + session.recordProviderSessionRef(provider.id); + providerSessionRefTracked = true; + } + } + let endpoint: Awaited>; + try { + endpoint = await ProxyForwarder.resolveStreamingHedgeEndpoint(session, provider); + } catch (error) { + rollbackLaunch(); + throw error; + } + let attemptSession: ProxySession; + try { + attemptSession = + provider.id === initialProvider.id + ? session + : ProxyForwarder.createStreamingShadowSession(session, provider); + attemptSession.setProvider(provider); + } catch (error) { + rollbackLaunch(); + throw error; } - const endpoint = await ProxyForwarder.resolveStreamingHedgeEndpoint(session, provider); - const attemptSession = - provider.id === initialProvider.id - ? session - : ProxyForwarder.createStreamingShadowSession(session, provider); - attemptSession.setProvider(provider); const controller = new AbortController(); const id = `${provider.id}:${sequence + 1}`; const attempt = { @@ -5137,26 +5159,43 @@ export class ProxyForwarder { attempt.reader = response.body.getReader(); while (!committed && !settled && attempt.pending) { const item = await attempt.reader.read(); - if (item.done) throw new EmptyResponseError(provider.id, provider.name, "empty_body"); + if (attempt.readerTransferred || committed || settled || !attempt.pending) break; + if (item.done) { + if (attempt.ready) break; + throw new EmptyResponseError(provider.id, provider.name, "empty_body"); + } if (!item.value || item.value.byteLength === 0) continue; attempt.chunks.push(item.value); const validity = attempt.parser.push(item.value); - if (validity.error || validity.terminal) + if (validity.error) throw new ProxyError("Invalid upstream discovery response", 502); + if (validity.ready) attempt.ready = true; + if (validity.terminal) { + if (attempt.ready) break; throw new ProxyError("Invalid upstream discovery response", 502); + } if (!validity.ready) continue; - attempt.ready = true; const normalPendingHigher = Array.from(attempts.values()).some( (other) => other.pending && other.kind === "normal" && (other.provider.priority || 0) < (provider.priority || 0) ); - if (normalPendingHigher) continue; + // Once a valid prefix is buffered behind a higher-priority normal + // attempt, stop reading. This leaves the reader idle so a later + // fallback promotion can transfer it without racing an in-flight + // read and losing bytes from the buffered response. + if (normalPendingHigher) return; const action = coordinator.markReady(id); if (action.type === "commit_normal" || action.type === "promote_fallback") await commit(attempt); return; } + if (attempt.ready && !committed && !settled && attempt.pending) { + const action = coordinator.markReady(id); + if (action.type === "commit_normal" || action.type === "promote_fallback") { + await commit(attempt); + } + } }) .catch(async (error) => { if (committed || settled || !attempt.pending) return; @@ -5180,6 +5219,7 @@ export class ProxyForwarder { attempt.releaseAgent?.(); releaseProviderRef(provider.id); const actionOwnsNextStep = + failureAction.type === "commit_normal" || failureAction.type === "promote_fallback" || failureAction.type === "launch" || failureAction.type === "terminal_failure"; @@ -5188,7 +5228,15 @@ export class ProxyForwarder { } if (!actionOwnsNextStep && !committed && !settled) { const replacement = await chooseCandidate(); - if (replacement) await launch(replacement, "normal"); + if (replacement) { + try { + await launch(replacement, "normal"); + } catch (launchError) { + lastError = + launchError instanceof Error ? launchError : new Error(String(launchError)); + noMoreCandidates = true; + } + } } if ( Array.from(attempts.values()).every((candidate) => !candidate.pending) && @@ -5207,20 +5255,37 @@ export class ProxyForwarder { }); }; - const launchNextRound = async () => { + const launchNextRound = async (slots: number, coordinatorAlreadyAdvanced = false) => { if (settled || committed) return; - currentRound += 1; - if (currentRound > maxRounds) return; - coordinator.beginRound(); - const candidate = await chooseCandidate(); - if (candidate) { + if (coordinatorAlreadyAdvanced) { + currentRound = coordinator.round; + } else { + const nextRound = coordinator.beginRound(); + currentRound = nextRound.round; + } + if (currentRound > maxRounds || slots <= 0) return; + const candidates = await ProxyProviderResolver.pickDiscoveryProviders( + session, + slots, + Array.from(launched) + ); + if (candidates.length === 0) { + noMoreCandidates = true; + } + for (const candidate of candidates) { try { await launch(candidate, "normal"); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - noMoreCandidates = true; + noMoreCandidates = false; } } + const hasPendingAttempt = Array.from(attempts.values()).some((attempt) => attempt.pending); + if (!hasPendingAttempt) { + await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); + return; + } + if (candidates.length === 0) return; if (!committed && !settled) { roundTimer = setTimeout(() => { void onBoundary().catch((error) => @@ -5246,8 +5311,9 @@ export class ProxyForwarder { if (action.promoteAttemptId) { const fallback = attempts.get(action.promoteAttemptId); if (fallback) fallback.kind = "fallback"; - if (action.type === "cancel" && currentRound < maxRounds) { - await launchNextRound(); + if (action.type === "cancel") { + if (fallback) await commit(fallback); + return; } } } @@ -5257,21 +5323,13 @@ export class ProxyForwarder { return; } if (action.type === "launch") { - await launchNextRound(); + await launchNextRound(action.slots, true); return; } if (action.type === "terminal_failure") { await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); return; } - if (action.type === "none") { - const fallbackPending = Array.from(attempts.values()).some( - (attempt) => attempt.pending && attempt.kind === "fallback" - ); - if (fallbackPending && currentRound < maxRounds) { - await launchNextRound(); - } - } }; const onBoundary = async () => { @@ -5279,6 +5337,7 @@ export class ProxyForwarder { await executeCoordinatorAction(coordinator.onRoundBoundary()); }; + let stickyProbeFailed = false; const cleanupAbort = bindClientAbortListener(session.clientAbortSignal, () => { if (settled || committed) return; void settleFailure(new ProxyError("Request aborted by client", 499, undefined, true)).catch( @@ -5298,6 +5357,7 @@ export class ProxyForwarder { try { await launch(initialProvider, "normal"); } catch (error) { + stickyProbeFailed = hasSticky; lastError = error instanceof Error ? error : new Error(String(error)); const replacement = await chooseCandidate(); if (replacement) { @@ -5316,32 +5376,40 @@ export class ProxyForwarder { } const initial = hasSticky ? concurrency - 1 : Math.max(0, concurrency - 1); if (hasSticky) { - stickyTimer = setTimeout(() => { - const sticky = Array.from(attempts.values()).find( - (attempt) => attempt.pending && attempt.provider.id === initialProvider.id - ); - if (sticky) { - sticky.kind = "fallback"; - if (bindingSnapshot && bindingSnapshot.providerId === initialProvider.id) { - void SessionManager.clearVersionedSessionProvider( - bindingSnapshot, - initialProvider.id, - Math.ceil((settings.stickyTimeoutCooldownMs ?? 300_000) / 1000) - ).catch((error) => - logger.debug("[Discovery] Failed to clear timed-out Sticky", { error }) - ); - } - if (currentRound < maxRounds) { - void launchNextRound().catch((error) => - logger.warn("[Discovery] Sticky round launch failed", { error }) - ); - } else { - void onBoundary().catch((error) => - logger.warn("[Discovery] Sticky boundary failed", { error }) - ); + if (stickyProbeFailed) { + roundTimer = setTimeout(() => { + void onBoundary().catch((error) => + logger.warn("[Discovery] Sticky failure boundary failed", { error }) + ); + }, discoverySlaMs); + } else { + stickyTimer = setTimeout(() => { + const sticky = Array.from(attempts.values()).find( + (attempt) => attempt.pending && attempt.provider.id === initialProvider.id + ); + if (sticky) { + sticky.kind = "fallback"; + if (bindingSnapshot && bindingSnapshot.providerId === initialProvider.id) { + void SessionManager.clearVersionedSessionProvider( + bindingSnapshot, + initialProvider.id, + Math.ceil((settings.stickyTimeoutCooldownMs ?? 300_000) / 1000) + ).catch((error) => + logger.debug("[Discovery] Failed to clear timed-out Sticky", { error }) + ); + } + if (currentRound < maxRounds) { + void launchNextRound(Math.max(0, concurrency - 1)).catch((error) => + logger.warn("[Discovery] Sticky round launch failed", { error }) + ); + } else { + void onBoundary().catch((error) => + logger.warn("[Discovery] Sticky boundary failed", { error }) + ); + } } - } - }, stickySlaMs); + }, stickySlaMs); + } } else { const candidates = await ProxyProviderResolver.pickDiscoveryProviders( session, @@ -5353,14 +5421,18 @@ export class ProxyForwarder { await launch(provider, "normal"); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - noMoreCandidates = true; + noMoreCandidates = false; } } - roundTimer = setTimeout(() => { - void onBoundary().catch((error) => - logger.warn("[Discovery] Round boundary failed", { error }) - ); - }, discoverySlaMs); + if (Array.from(attempts.values()).some((attempt) => attempt.pending)) { + roundTimer = setTimeout(() => { + void onBoundary().catch((error) => + logger.warn("[Discovery] Round boundary failed", { error }) + ); + }, discoverySlaMs); + } else if (!settled && !committed) { + await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); + } } const result = await resultPromise; if (result.error) throw result.error; diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index dd0ab0996..ad00a4ecd 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -11891,7 +11891,7 @@ export interface operations { discoveryConcurrency: number; /** @description Maximum number of Discovery rounds. */ maxDiscoveryRounds: number; - /** @description 首字 Discovery SLA in milliseconds. */ + /** @description 首字节 Discovery SLA in milliseconds. */ discoverySlaMs: number; /** @description Sticky probe SLA in milliseconds. */ stickySlaMs: number; @@ -12167,7 +12167,7 @@ export interface operations { discoveryConcurrency?: number; /** @description Maximum number of Discovery rounds. */ maxDiscoveryRounds?: number; - /** @description 首字 Discovery SLA in milliseconds. */ + /** @description 首字节 Discovery SLA in milliseconds. */ discoverySlaMs?: number; /** @description Sticky probe SLA in milliseconds. */ stickySlaMs?: number; @@ -12316,7 +12316,7 @@ export interface operations { discoveryConcurrency: number; /** @description Maximum number of Discovery rounds. */ maxDiscoveryRounds: number; - /** @description 首字 Discovery SLA in milliseconds. */ + /** @description 首字节 Discovery SLA in milliseconds. */ discoverySlaMs: number; /** @description Sticky probe SLA in milliseconds. */ stickySlaMs: number; diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index e52e21b17..f27d5993e 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -105,7 +105,7 @@ export const SystemSettingsSchema = z .positive() .describe("Maximum number of normal Discovery attempts in the initial batch."), maxDiscoveryRounds: z.number().int().positive().describe("Maximum number of Discovery rounds."), - discoverySlaMs: z.number().int().positive().describe("首字 Discovery SLA in milliseconds."), + discoverySlaMs: z.number().int().positive().describe("首字节 Discovery SLA in milliseconds."), stickySlaMs: z.number().int().positive().describe("Sticky probe SLA in milliseconds."), racingTotalTimeoutMs: z .number() diff --git a/tests/unit/proxy/discovery-coordinator.test.ts b/tests/unit/proxy/discovery-coordinator.test.ts index 35585c473..9aeede802 100644 --- a/tests/unit/proxy/discovery-coordinator.test.ts +++ b/tests/unit/proxy/discovery-coordinator.test.ts @@ -74,4 +74,47 @@ describe("DiscoveryCoordinator", () => { cancelAttemptIds: ["normal"], }); }); + + it("commits a ready lower-priority candidate when the higher tier fails", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 2 }); + coordinator.addAttempt(attempt("high", 1)); + coordinator.addAttempt(attempt("low", 10)); + expect(coordinator.markReady("low")).toEqual({ type: "none" }); + expect(coordinator.markFailed("high")).toEqual({ + type: "commit_normal", + attemptId: "low", + }); + }); + + it("treats fallback promotion as terminal", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 2 }); + coordinator.addAttempt(attempt("fallback", 1, "fallback")); + + expect(coordinator.markReady("fallback")).toEqual({ + type: "promote_fallback", + attemptId: "fallback", + }); + expect(coordinator.state).toBe("FALLBACK_ACTIVE"); + expect(coordinator.isTerminal).toBe(true); + expect(coordinator.markFailed("fallback")).toEqual({ type: "none" }); + }); + + it("opens a full new round when all normal attempts fail", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 3, maxRounds: 2 }); + coordinator.addAttempt(attempt("a", 1)); + coordinator.addAttempt(attempt("b", 2)); + + expect(coordinator.markFailed("a")).toEqual({ type: "none" }); + expect(coordinator.markFailed("b")).toEqual({ type: "launch", slots: 3 }); + expect(coordinator.round).toBe(2); + }); + + it("commits a ready normal candidate at the total deadline", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 2 }); + coordinator.addAttempt(attempt("high", 1)); + coordinator.addAttempt(attempt("normal", 2)); + coordinator.markReady("normal"); + + expect(coordinator.onDeadline()).toEqual({ type: "commit_normal", attemptId: "normal" }); + }); }); From 420802af9873bc9f2d76180e0a0abd539ef05d29 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 15:34:36 -0400 Subject: [PATCH 07/32] docs(discovery): document Redis cluster capability fallback --- docs/streaming-discovery.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/streaming-discovery.md b/docs/streaming-discovery.md index 694d560d0..39d261241 100644 --- a/docs/streaming-discovery.md +++ b/docs/streaming-discovery.md @@ -46,6 +46,14 @@ 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. From 96cbae653d4fbf9dc4928ad462a5f8a1ca69fc51 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 15:36:40 -0400 Subject: [PATCH 08/32] fix(discovery): synchronize Sticky fallback promotion --- src/app/v1/_lib/proxy/discovery-coordinator.ts | 10 ++++++++++ src/app/v1/_lib/proxy/forwarder.ts | 1 + tests/unit/proxy/discovery-coordinator.test.ts | 8 ++++++++ 3 files changed, 19 insertions(+) diff --git a/src/app/v1/_lib/proxy/discovery-coordinator.ts b/src/app/v1/_lib/proxy/discovery-coordinator.ts index 92bb0880c..e6937e7c0 100644 --- a/src/app/v1/_lib/proxy/discovery-coordinator.ts +++ b/src/app/v1/_lib/proxy/discovery-coordinator.ts @@ -86,6 +86,16 @@ export class DiscoveryCoordinator { this.attempts.delete(id); } + /** 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; + attempt.kind = "fallback"; + this.state = "FALLBACK_READY_HELD"; + return true; + } + get isTerminal(): boolean { return ( this.state === "WINNER_COMMITTED" || diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index a90a6c622..033d28c53 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -5389,6 +5389,7 @@ export class ProxyForwarder { ); if (sticky) { sticky.kind = "fallback"; + coordinator.promoteToFallback(sticky.id); if (bindingSnapshot && bindingSnapshot.providerId === initialProvider.id) { void SessionManager.clearVersionedSessionProvider( bindingSnapshot, diff --git a/tests/unit/proxy/discovery-coordinator.test.ts b/tests/unit/proxy/discovery-coordinator.test.ts index 9aeede802..69dee7127 100644 --- a/tests/unit/proxy/discovery-coordinator.test.ts +++ b/tests/unit/proxy/discovery-coordinator.test.ts @@ -99,6 +99,14 @@ describe("DiscoveryCoordinator", () => { expect(coordinator.markFailed("fallback")).toEqual({ type: "none" }); }); + it("keeps coordinator kind in sync when a running Sticky becomes fallback", () => { + const coordinator = new DiscoveryCoordinator({ concurrency: 2, maxRounds: 2 }); + coordinator.addAttempt(attempt("sticky", 1)); + + expect(coordinator.promoteToFallback("sticky")).toBe(true); + expect(coordinator.snapshot.find((item) => item.id === "sticky")?.kind).toBe("fallback"); + }); + it("opens a full new round when all normal attempts fail", () => { const coordinator = new DiscoveryCoordinator({ concurrency: 3, maxRounds: 2 }); coordinator.addAttempt(attempt("a", 1)); From e453b8e78459bc6be8112dffc722b519eed78b68 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 15:38:27 -0400 Subject: [PATCH 09/32] fix(discovery): probe binding capability before eligibility --- src/app/v1/_lib/proxy/forwarder.ts | 11 +++++++---- src/lib/session-manager.ts | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 033d28c53..1915831b1 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3831,10 +3831,13 @@ export class ProxyForwarder { if (settings.discoveryEnabled !== true) { return false; } - if ( - typeof SessionManager.getVersionedBindingCapabilityState !== "function" || - SessionManager.getVersionedBindingCapabilityState() !== "available" - ) { + const capabilityState = + typeof SessionManager.ensureVersionedBindingCapability === "function" + ? await SessionManager.ensureVersionedBindingCapability() + : typeof SessionManager.getVersionedBindingCapabilityState === "function" + ? SessionManager.getVersionedBindingCapabilityState() + : "unknown"; + if (capabilityState !== "available") { return false; } const endpointPolicy = ProxyForwarder.getEndpointPolicy(session); diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index b47b9a3bf..f73c33fe0 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -34,6 +34,7 @@ import { import { clearSessionBinding as clearVersionedSessionBinding, compareAndSetSessionBinding, + ensureVersionedBindingCapability, mutateLegacySessionBindingSafely, readOrReconcileSessionBinding, isSessionProviderCoolingDown as readSessionProviderCooldown, @@ -668,6 +669,10 @@ export class SessionManager { return readVersionedBindingCapabilityState(); } + static async ensureVersionedBindingCapability(): Promise { + return ensureVersionedBindingCapability(); + } + static async getSessionBindingSnapshot( sessionId: string, keyId: number From 4c9690d46baf53ccd660b5720bb16bb954a6a5fb Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 15:42:46 -0400 Subject: [PATCH 10/32] fix(discovery): reject stale attempt boundary events --- .../v1/_lib/proxy/discovery-coordinator.ts | 2 +- src/app/v1/_lib/proxy/forwarder.ts | 27 +++++++++---------- .../unit/proxy/discovery-coordinator.test.ts | 1 + 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/app/v1/_lib/proxy/discovery-coordinator.ts b/src/app/v1/_lib/proxy/discovery-coordinator.ts index e6937e7c0..030ccd38c 100644 --- a/src/app/v1/_lib/proxy/discovery-coordinator.ts +++ b/src/app/v1/_lib/proxy/discovery-coordinator.ts @@ -147,7 +147,7 @@ export class DiscoveryCoordinator { ): DiscoveryAction { if (!this.acceptsEpoch(requestEpoch, roundEpoch) || this.isTerminal) return { type: "none" }; const attempt = this.attempts.get(id); - if (!attempt) return { type: "none" }; + if (!attempt?.pending) return { type: "none" }; attempt.pending = false; attempt.ready = false; const readyAction = this.chooseReadyNormal(); diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 1915831b1..59de0b133 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -5034,6 +5034,15 @@ export class ProxyForwarder { return candidates[0]; }; + const scheduleRoundBoundary = (delayMs: number) => { + const epoch = coordinator.epochs; + roundTimer = setTimeout(() => { + void executeCoordinatorAction( + coordinator.onRoundBoundary(epoch.requestEpoch, epoch.roundEpoch) + ).catch((error) => logger.warn("[Discovery] Round boundary failed", { error })); + }, delayMs); + }; + const launch = async (provider: Provider, kind: "normal" | "fallback"): Promise => { if (settled || committed || launched.has(provider.id)) return; launched.add(provider.id); @@ -5290,11 +5299,7 @@ export class ProxyForwarder { } if (candidates.length === 0) return; if (!committed && !settled) { - roundTimer = setTimeout(() => { - void onBoundary().catch((error) => - logger.warn("[Discovery] Round boundary failed", { error }) - ); - }, discoverySlaMs); + scheduleRoundBoundary(discoverySlaMs); } }; @@ -5380,11 +5385,7 @@ export class ProxyForwarder { const initial = hasSticky ? concurrency - 1 : Math.max(0, concurrency - 1); if (hasSticky) { if (stickyProbeFailed) { - roundTimer = setTimeout(() => { - void onBoundary().catch((error) => - logger.warn("[Discovery] Sticky failure boundary failed", { error }) - ); - }, discoverySlaMs); + scheduleRoundBoundary(discoverySlaMs); } else { stickyTimer = setTimeout(() => { const sticky = Array.from(attempts.values()).find( @@ -5429,11 +5430,7 @@ export class ProxyForwarder { } } if (Array.from(attempts.values()).some((attempt) => attempt.pending)) { - roundTimer = setTimeout(() => { - void onBoundary().catch((error) => - logger.warn("[Discovery] Round boundary failed", { error }) - ); - }, discoverySlaMs); + scheduleRoundBoundary(discoverySlaMs); } else if (!settled && !committed) { await settleFailure(ProxyForwarder.buildAllProvidersUnavailableError(lastError)); } diff --git a/tests/unit/proxy/discovery-coordinator.test.ts b/tests/unit/proxy/discovery-coordinator.test.ts index 69dee7127..69a7bbbeb 100644 --- a/tests/unit/proxy/discovery-coordinator.test.ts +++ b/tests/unit/proxy/discovery-coordinator.test.ts @@ -44,6 +44,7 @@ describe("DiscoveryCoordinator", () => { expect(coordinator.markReady("a", epoch.requestEpoch, epoch.roundEpoch)).toEqual({ type: "none", }); + expect(coordinator.markFailed("a")).toEqual({ type: "none" }); }); it("promotes a ready fallback at the round boundary when no normal is ready", () => { From 8cbf3be719195b4a7d90c64c2b1b38cd82a79c68 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 15:58:35 -0400 Subject: [PATCH 11/32] fix(discovery): honor configured sticky SLA and validation --- drizzle/0110_daffy_rawhide_kid.sql | 2 +- src/actions/system-config.ts | 22 +++++++------------ .../_components/system-settings-form.tsx | 6 ++++- src/app/v1/_lib/proxy/forwarder.ts | 19 ++++++---------- src/lib/api-client/v1/openapi-types.gen.ts | 6 ++--- src/lib/api/v1/schemas/system-config.ts | 6 ++++- src/lib/config/system-settings-cache.ts | 2 +- .../system-settings-discovery.test.ts | 10 +++++++++ 8 files changed, 40 insertions(+), 33 deletions(-) diff --git a/drizzle/0110_daffy_rawhide_kid.sql b/drizzle/0110_daffy_rawhide_kid.sql index f52f66675..ff57e9a44 100644 --- a/drizzle/0110_daffy_rawhide_kid.sql +++ b/drizzle/0110_daffy_rawhide_kid.sql @@ -4,4 +4,4 @@ ALTER TABLE "system_settings" ADD COLUMN "max_discovery_rounds" integer DEFAULT 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; \ No newline at end of file +ALTER TABLE "system_settings" ADD COLUMN "sticky_timeout_cooldown_ms" integer DEFAULT 300000 NOT NULL; diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 2bbf59be8..40e46320b 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -5,6 +5,7 @@ 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"; @@ -25,12 +26,7 @@ import type { } from "@/types/system-config"; import type { ActionResult } from "./types"; -const DEFAULT_DISCOVERY_WINDOW = { - discoverySlaMs: 10_000, - stickySlaMs: 20_000, - maxDiscoveryRounds: 2, - racingTotalTimeoutMs: 60_000, -} as const; +const DISCOVERY_WINDOW_INVALID = "DISCOVERY_WINDOW_INVALID"; export async function fetchSystemSettings(): Promise> { try { @@ -126,19 +122,16 @@ export async function saveSystemSettings(formData: { const validated = UpdateSystemSettingsSchema.parse(formData); const effectiveDiscoveryWindow = { discoverySlaMs: - validated.discoverySlaMs ?? - before?.discoverySlaMs ?? - DEFAULT_DISCOVERY_WINDOW.discoverySlaMs, - stickySlaMs: - validated.stickySlaMs ?? before?.stickySlaMs ?? DEFAULT_DISCOVERY_WINDOW.stickySlaMs, + validated.discoverySlaMs ?? before?.discoverySlaMs ?? DEFAULT_SETTINGS.discoverySlaMs, + stickySlaMs: validated.stickySlaMs ?? before?.stickySlaMs ?? DEFAULT_SETTINGS.stickySlaMs, maxDiscoveryRounds: validated.maxDiscoveryRounds ?? before?.maxDiscoveryRounds ?? - DEFAULT_DISCOVERY_WINDOW.maxDiscoveryRounds, + DEFAULT_SETTINGS.maxDiscoveryRounds, racingTotalTimeoutMs: validated.racingTotalTimeoutMs ?? before?.racingTotalTimeoutMs ?? - DEFAULT_DISCOVERY_WINDOW.racingTotalTimeoutMs, + DEFAULT_SETTINGS.racingTotalTimeoutMs, }; if ( effectiveDiscoveryWindow.racingTotalTimeoutMs < @@ -147,7 +140,8 @@ export async function saveSystemSettings(formData: { ) { return { ok: false, - error: "竞速总超时必须不小于 Sticky SLA + Discovery 轮数 × Discovery SLA", + error: "Discovery window validation failed.", + errorCode: DISCOVERY_WINDOW_INVALID, }; } const updated = await updateSystemSettings({ 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 ae3307d22..2642cbf8a 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -393,7 +393,11 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) }); if (!result.ok) { - toast.error(result.error || t("saveFailed")); + const errorMessage = + result.errorCode === "DISCOVERY_WINDOW_INVALID" + ? t("discoveryWindowInvalid") + : result.error || t("saveFailed"); + toast.error(errorMessage); return; } diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 59de0b133..9965e311a 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -4847,7 +4847,10 @@ export class ProxyForwarder { const concurrency = Math.max(1, Math.floor(settings.discoveryConcurrency ?? 2)); const maxRounds = Math.max(1, Math.floor(settings.maxDiscoveryRounds ?? 2)); const discoverySlaMs = Math.max(1, settings.discoverySlaMs ?? 10_000); - const stickySlaMs = Math.max(discoverySlaMs, settings.stickySlaMs ?? 20_000); + // Respect the configured Sticky budget. The settings validator already + // checks the total pre-winner window; a shorter Sticky SLA is a valid + // deliberate choice and must not be silently expanded at runtime. + const stickySlaMs = Math.max(1, settings.stickySlaMs ?? 20_000); const totalTimeoutMs = Math.max(stickySlaMs, settings.racingTotalTimeoutMs ?? 60_000); const protocol = ProxyForwarder.discoveryProtocol(session); const coordinator = new DiscoveryCoordinator({ concurrency, maxRounds }); @@ -5186,17 +5189,9 @@ export class ProxyForwarder { throw new ProxyError("Invalid upstream discovery response", 502); } if (!validity.ready) continue; - const normalPendingHigher = Array.from(attempts.values()).some( - (other) => - other.pending && - other.kind === "normal" && - (other.provider.priority || 0) < (provider.priority || 0) - ); - // Once a valid prefix is buffered behind a higher-priority normal - // attempt, stop reading. This leaves the reader idle so a later - // fallback promotion can transfer it without racing an in-flight - // read and losing bytes from the buffered response. - if (normalPendingHigher) return; + // Record readiness even when the priority gate holds this attempt. + // The coordinator can then promote the buffered stream if the + // higher-priority attempt fails or the round closes. const action = coordinator.markReady(id); if (action.type === "commit_normal" || action.type === "promote_fallback") await commit(attempt); diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index ad00a4ecd..4af7d75bd 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -11891,7 +11891,7 @@ export interface operations { discoveryConcurrency: number; /** @description Maximum number of Discovery rounds. */ maxDiscoveryRounds: number; - /** @description 首字节 Discovery SLA in milliseconds. */ + /** @description First-byte Discovery SLA in milliseconds. */ discoverySlaMs: number; /** @description Sticky probe SLA in milliseconds. */ stickySlaMs: number; @@ -12167,7 +12167,7 @@ export interface operations { discoveryConcurrency?: number; /** @description Maximum number of Discovery rounds. */ maxDiscoveryRounds?: number; - /** @description 首字节 Discovery SLA in milliseconds. */ + /** @description First-byte Discovery SLA in milliseconds. */ discoverySlaMs?: number; /** @description Sticky probe SLA in milliseconds. */ stickySlaMs?: number; @@ -12316,7 +12316,7 @@ export interface operations { discoveryConcurrency: number; /** @description Maximum number of Discovery rounds. */ maxDiscoveryRounds: number; - /** @description 首字节 Discovery SLA in milliseconds. */ + /** @description First-byte Discovery SLA in milliseconds. */ discoverySlaMs: number; /** @description Sticky probe SLA in milliseconds. */ stickySlaMs: number; diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index f27d5993e..cdd5d7a12 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -105,7 +105,11 @@ export const SystemSettingsSchema = z .positive() .describe("Maximum number of normal Discovery attempts in the initial batch."), maxDiscoveryRounds: z.number().int().positive().describe("Maximum number of Discovery rounds."), - discoverySlaMs: z.number().int().positive().describe("首字节 Discovery SLA in milliseconds."), + discoverySlaMs: z + .number() + .int() + .positive() + .describe("First-byte Discovery SLA in milliseconds."), stickySlaMs: z.number().int().positive().describe("Sticky probe SLA in milliseconds."), racingTotalTimeoutMs: z .number() diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 0dd733b75..fd3cee750 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -32,7 +32,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" diff --git a/tests/unit/validation/system-settings-discovery.test.ts b/tests/unit/validation/system-settings-discovery.test.ts index f6b7df030..14841cb4b 100644 --- a/tests/unit/validation/system-settings-discovery.test.ts +++ b/tests/unit/validation/system-settings-discovery.test.ts @@ -26,6 +26,16 @@ describe("UpdateSystemSettingsSchema Discovery settings", () => { ).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, From e95f0d0555d607248ebc1dce377a99cd657d7e93 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 16:03:02 -0400 Subject: [PATCH 12/32] test(discovery): cover complete ready stream --- tests/unit/proxy/discovery-validity.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index 83feb4f8e..408fca35a 100644 --- a/tests/unit/proxy/discovery-validity.test.ts +++ b/tests/unit/proxy/discovery-validity.test.ts @@ -74,4 +74,16 @@ describe("discovery validity", () => { error: false, }); }); + + it("keeps ready when content and the terminal marker arrive in one read", () => { + const parser = new DiscoveryValidityParser("openai-chat"); + + expect( + parser.push('data: {"choices":[{"delta":{"content":"done"}}]}\n\ndata: [DONE]\n\n') + ).toEqual({ + ready: true, + terminal: true, + error: false, + }); + }); }); From 309d6ca28202f5a2300f525d4b6fadf583094e6e Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 16:09:33 -0400 Subject: [PATCH 13/32] test(settings): cover discovery window boundary --- .../unit/validation/system-settings-discovery.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/validation/system-settings-discovery.test.ts b/tests/unit/validation/system-settings-discovery.test.ts index 14841cb4b..1950ef225 100644 --- a/tests/unit/validation/system-settings-discovery.test.ts +++ b/tests/unit/validation/system-settings-discovery.test.ts @@ -26,6 +26,17 @@ describe("UpdateSystemSettingsSchema Discovery settings", () => { ).toThrow("竞速总超时"); }); + 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, From 0de55d73442df4db74b47f016106a1aa0b6a1ca1 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 16:13:04 -0400 Subject: [PATCH 14/32] fix(discovery): recognize Anthropic tool deltas --- src/app/v1/_lib/proxy/discovery-validity.ts | 3 +++ tests/unit/proxy/discovery-validity.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/app/v1/_lib/proxy/discovery-validity.ts b/src/app/v1/_lib/proxy/discovery-validity.ts index d4ece4eff..7d53493b7 100644 --- a/src/app/v1/_lib/proxy/discovery-validity.ts +++ b/src/app/v1/_lib/proxy/discovery-validity.ts @@ -27,6 +27,9 @@ function hasContent(value: unknown): boolean { "functionCall", "function_call", "arguments", + "partial_json", + "id", + "name", "input", "parts", ].some((key) => hasContent(object[key])); diff --git a/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index 408fca35a..c39065ebe 100644 --- a/tests/unit/proxy/discovery-validity.test.ts +++ b/tests/unit/proxy/discovery-validity.test.ts @@ -86,4 +86,17 @@ describe("discovery validity", () => { error: false, }); }); + + it("accepts Anthropic tool-use partial JSON as deliverable content", () => { + const parser = new DiscoveryValidityParser("anthropic"); + + expect( + parser.push('data: {"type":"content_block_delta","delta":{"partial_json":"{\\"x\\":1}"}}\n\n') + ).toMatchObject({ ready: true, error: false }); + expect(parser.push('data: {"type":"message_stop"}\n\n')).toMatchObject({ + ready: true, + terminal: true, + error: false, + }); + }); }); From 059de384037785af71628da8d71865bf6ff82263 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 16:30:18 -0400 Subject: [PATCH 15/32] fix(discovery): preserve binding safety and tool prefixes --- src/app/v1/_lib/proxy/discovery-validity.ts | 1 + src/app/v1/_lib/proxy/forwarder.ts | 9 ++ src/app/v1/_lib/proxy/response-handler.ts | 53 +++++++-- tests/unit/proxy/discovery-validity.test.ts | 12 ++ ...handler-endpoint-circuit-isolation.test.ts | 109 ++++++++++++++++++ 5 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/app/v1/_lib/proxy/discovery-validity.ts b/src/app/v1/_lib/proxy/discovery-validity.ts index 7d53493b7..21f91c005 100644 --- a/src/app/v1/_lib/proxy/discovery-validity.ts +++ b/src/app/v1/_lib/proxy/discovery-validity.ts @@ -26,6 +26,7 @@ function hasContent(value: unknown): boolean { "tool_calls", "functionCall", "function_call", + "function", "arguments", "partial_json", "id", diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 9965e311a..864d43313 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -5225,6 +5225,15 @@ export class ProxyForwarder { } attempt.releaseAgent?.(); releaseProviderRef(provider.id); + if (lastErrorCategory === ErrorCategory.NON_RETRYABLE_CLIENT_ERROR) { + // Client/input errors are independent of the selected provider. + // Stop Discovery immediately so the same invalid request is not + // fanned out or masked by a later generic fallback error. + await settleFailure( + ProxyForwarder.resolveHedgeTerminalError(lastError, lastErrorCategory) + ); + return; + } const actionOwnsNextStep = failureAction.type === "commit_normal" || failureAction.type === "promote_fallback" || diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index bc36a1a39..3b7ee3d7f 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -15,6 +15,7 @@ import { RateLimitService } from "@/lib/rate-limit"; import { deleteLiveChain } from "@/lib/redis/live-chain-store"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; +import type { SessionBindingSnapshot } from "@/lib/redis/session-binding"; import { CODEX_1M_CONTEXT_TOKEN_THRESHOLD } from "@/lib/special-attributes"; import type { CostBreakdown, @@ -1152,16 +1153,55 @@ function finalizeDeferredStreamingFinalizationIfNeeded( const keyId = session.authState?.key?.id ?? session.messageContext?.key?.id ?? null; if (meta?.bindingIntent === "none") return; if (meta?.bindingSnapshot && keyId != null) { - await SessionManager.clearVersionedSessionProvider( + const cleared = await SessionManager.clearVersionedSessionProvider( meta.bindingSnapshot, - providerIdForPersistence, + meta.bindingSnapshot.providerId, 0 ); + if (cleared.status !== "ok") { + logger.debug("[ResponseHandler] Discovery binding clear skipped", { + sessionId: meta.bindingSnapshot.sessionId, + keyId: meta.bindingSnapshot.keyId, + expectedProviderId: meta.bindingSnapshot.providerId, + reason: cleared.reason, + }); + } return; } await SessionManager.clearSessionProvider(session.sessionId, providerIdForPersistence, keyId); }; + const compareAndSetDiscoveryBinding = async ( + snapshot: SessionBindingSnapshot, + providerId: number, + keyId: number + ) => { + if (!snapshot) + return { updated: false, reason: "missing_snapshot", details: "missing_snapshot" }; + + let cas = await SessionManager.compareAndSetSessionProvider(snapshot, providerId); + if (cas.status === "conflict" && cas.reason === "canonical_missing") { + // A long stream can outlive SESSION_TTL. Reconcile once before giving up, + // but only retry when the binding still represents the same owner/provider + // observed before the stream. This preserves CAS protection against a + // concurrent request that established a different Sticky in the meantime. + const refreshed = await SessionManager.getSessionBindingSnapshot( + session.sessionId ?? snapshot.sessionId, + keyId + ); + if (refreshed.status === "ok" && refreshed.snapshot.providerId === snapshot.providerId) { + session.setSessionBindingSnapshot(refreshed.snapshot); + cas = await SessionManager.compareAndSetSessionProvider(refreshed.snapshot, providerId); + } + } + + return { + updated: cas.status === "ok", + reason: cas.status === "ok" ? "discovery_generation_cas" : cas.reason, + details: cas.status, + }; + }; + const isHedgeWinner = meta?.isHedgeWinner === true; const billHedgeLosers = meta?.billHedgeLosers === true; @@ -1545,14 +1585,7 @@ function finalizeDeferredStreamingFinalizationIfNeeded( const keyId = session.authState?.key?.id ?? session.messageContext?.key?.id ?? null; const result = meta.bindingSnapshot && keyId != null - ? await SessionManager.compareAndSetSessionProvider( - meta.bindingSnapshot, - meta.providerId - ).then((cas) => ({ - updated: cas.status === "ok", - reason: cas.status === "ok" ? "discovery_generation_cas" : cas.reason, - details: cas.status, - })) + ? await compareAndSetDiscoveryBinding(meta.bindingSnapshot, meta.providerId, keyId) : await SessionManager.updateSessionBindingSmart( session.sessionId, meta.providerId, diff --git a/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index c39065ebe..392a2a75a 100644 --- a/tests/unit/proxy/discovery-validity.test.ts +++ b/tests/unit/proxy/discovery-validity.test.ts @@ -99,4 +99,16 @@ describe("discovery validity", () => { error: false, }); }); + + it("accepts nested OpenAI Chat tool-call arguments", () => { + expect( + parserForOpenAIChatToolCall().push( + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"x\\":1}"}}]}}]}\n\n' + ) + ).toMatchObject({ ready: true, error: false }); + }); }); + +function parserForOpenAIChatToolCall(): DiscoveryValidityParser { + return new DiscoveryValidityParser("openai-chat"); +} 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 af432f126..e6c2ec026 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -77,6 +77,9 @@ vi.mock("@/lib/session-manager", () => ({ updateSessionUsage: vi.fn(), storeSessionResponse: vi.fn(), clearSessionProvider: vi.fn(), + clearVersionedSessionProvider: vi.fn(), + compareAndSetSessionProvider: vi.fn(), + getSessionBindingSnapshot: vi.fn(), extractCodexPromptCacheKey: vi.fn(), updateSessionBindingSmart: vi.fn(), updateSessionProvider: vi.fn(), @@ -335,6 +338,23 @@ function createSuccessStreamResponse(): Response { }); } +function createSuccessStreamResponseWithCompletion(): Response { + const sseText = + `data: ${JSON.stringify({ type: "content_block_delta", delta: { text: "ok" } })}\n\n` + + `data: ${JSON.stringify({ type: "message_stop" })}\n\n`; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sseText)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + async function drainAsyncTasks(): Promise { while (asyncTasks.length > 0) { const tasks = asyncTasks.splice(0, asyncTasks.length); @@ -366,6 +386,39 @@ function setupCommonMocks() { vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); vi.mocked(SessionManager.clearSessionProvider).mockResolvedValue(undefined); + vi.mocked(SessionManager.clearVersionedSessionProvider).mockResolvedValue({ + status: "ok", + source: "cleared", + snapshot: { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: "cleared", + }, + legacyFallbackAllowed: false, + }); + vi.mocked(SessionManager.compareAndSetSessionProvider).mockResolvedValue({ + status: "ok", + source: "updated", + snapshot: { + sessionId: "fake-session", + keyId: 456, + providerId: 1, + generation: "updated", + }, + legacyFallbackAllowed: false, + }); + vi.mocked(SessionManager.getSessionBindingSnapshot).mockResolvedValue({ + status: "ok", + source: "existing", + snapshot: { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: "fresh", + }, + legacyFallbackAllowed: false, + }); vi.mocked(SessionManager.updateSessionUsage).mockResolvedValue(undefined); vi.mocked(SessionManager.updateSessionBindingSmart).mockResolvedValue({ updated: true, @@ -537,4 +590,60 @@ describe("Endpoint circuit breaker isolation", () => { expect(mockRecordEndpointSuccess).not.toHaveBeenCalled(); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); }); + + it("reconciles an expired Discovery binding snapshot before retrying CAS", async () => { + const session = createSession(); + const snapshot = { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: "expired-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, + }); + vi.mocked(SessionManager.compareAndSetSessionProvider) + .mockResolvedValueOnce({ + status: "conflict", + reason: "canonical_missing", + legacyFallbackAllowed: false, + }) + .mockResolvedValueOnce({ + status: "ok", + source: "updated", + snapshot: { + ...snapshot, + providerId: 1, + generation: "renewed-generation", + }, + legacyFallbackAllowed: false, + }); + + const clientResponse = await ProxyResponseHandler.dispatch( + session, + createSuccessStreamResponseWithCompletion() + ); + await clientResponse.text(); + await drainAsyncTasks(); + + expect(SessionManager.getSessionBindingSnapshot).toHaveBeenCalledWith("fake-session", 456); + expect(SessionManager.compareAndSetSessionProvider).toHaveBeenNthCalledWith(1, snapshot, 1); + expect(SessionManager.compareAndSetSessionProvider).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ generation: "fresh", providerId: null }), + 1 + ); + }); }); From f98a5582dd52fb51e7f32c2309ccda70a8c9dba1 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 16:47:46 -0400 Subject: [PATCH 16/32] fix(discovery): preserve rectifier and sticky binding semantics --- src/app/v1/_lib/proxy/forwarder.ts | 133 +++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 16 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 864d43313..3f9c362dd 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -4853,6 +4853,7 @@ export class ProxyForwarder { const stickySlaMs = Math.max(1, settings.stickySlaMs ?? 20_000); const totalTimeoutMs = Math.max(stickySlaMs, settings.racingTotalTimeoutMs ?? 60_000); const protocol = ProxyForwarder.discoveryProtocol(session); + const rawCrossProviderFallbackEnabled = session.isRawCrossProviderFallbackEnabled(); const coordinator = new DiscoveryCoordinator({ concurrency, maxRounds }); const bindingKeyId = session.authState?.key?.id ?? session.messageContext?.key?.id ?? null; // Provider selection normally populates this snapshot for a reused Sticky. @@ -4910,6 +4911,14 @@ export class ProxyForwarder { } }; + const getAttemptModelRedirect = (attempt: (typeof winner & { id: string }) | null) => { + if (!attempt) return undefined; + if (attempt.modelRedirect !== undefined) return attempt.modelRedirect; + const redirect = attempt.session.getCurrentModelRedirect(attempt.provider.id); + if (redirect) attempt.modelRedirect = structuredClone(redirect); + return attempt.modelRedirect; + }; + const cancelAttempt = (attempt: (typeof winner & { id: string }) | null, reason: string) => { if (attempt?.readerTransferred) return; if (!attempt?.pending) return; @@ -5046,7 +5055,15 @@ export class ProxyForwarder { }, delayMs); }; - const launch = async (provider: Provider, kind: "normal" | "fallback"): Promise => { + const launch = async ( + provider: Provider, + kind: "normal" | "fallback", + options?: { + attemptSession?: ProxySession; + requestAttemptCount?: number; + retryState?: ReactiveRectifierRetryState; + } + ): Promise => { if (settled || committed || launched.has(provider.id)) return; launched.add(provider.id); let providerSessionRefTracked = false; @@ -5083,9 +5100,10 @@ export class ProxyForwarder { let attemptSession: ProxySession; try { attemptSession = - provider.id === initialProvider.id + options?.attemptSession ?? + (provider.id === initialProvider.id ? session - : ProxyForwarder.createStreamingShadowSession(session, provider); + : ProxyForwarder.createStreamingShadowSession(session, provider)); attemptSession.setProvider(provider); } catch (error) { rollbackLaunch(); @@ -5112,8 +5130,8 @@ export class ProxyForwarder { clearResponseTimeout: null, firstByteTimeoutMs: 0, sequence: ++sequence, - requestAttemptCount: 1, - reactiveRectifierRetryState: { + requestAttemptCount: options?.requestAttemptCount ?? 1, + reactiveRectifierRetryState: options?.retryState ?? { thinkingSignatureRetried: false, thinkingBudgetRetried: false, thinkingEffortConflictRetried: false, @@ -5206,16 +5224,73 @@ export class ProxyForwarder { }) .catch(async (error) => { if (committed || settled || !attempt.pending) return; - attempt.pending = false; - const failureAction = coordinator.markFailed(id); lastError = error instanceof Error ? error : new Error(String(error)); lastErrorCategory = await categorizeErrorAsync(lastError); + const errorMessage = + lastError instanceof ProxyError + ? lastError.getDetailedErrorMessage() + : lastError.message; + + // Preserve the existing provider-local rectifier contract before + // classifying a 400 as terminal. The rectifier mutates the shadow + // request session, so retry the same attempt session rather than + // creating a fresh unrectified shadow from the parent session. + const rectifier = await tryApplyReactiveRectifier({ + provider, + requestSession: attempt.session, + persistSession: session, + errorMessage, + attemptNumber: attempt.requestAttemptCount, + retryAttemptNumber: attempt.requestAttemptCount + 1, + retryState: attempt.reactiveRectifierRetryState, + }); + if (rectifier.matched && rectifier.applied) { + attempt.pending = false; + coordinator.removeAttempt(id); + const readerCancel = attempt.reader?.cancel("discovery_rectifier_retry"); + readerCancel?.catch(() => undefined); + attempt.releaseAgent?.(); + releaseProviderRef(provider.id); + session.addProviderToChain(provider, { + ...buildRetryFailedChainEntry( + provider, + attempt.endpointAudit, + attempt.requestAttemptCount, + lastError, + errorMessage, + rectifier.requestDetailsBeforeRectify, + rawCrossProviderFallbackEnabled + ), + modelRedirect: getAttemptModelRedirect(attempt), + }); + launched.delete(provider.id); + try { + await launch(provider, attempt.kind, { + attemptSession: attempt.session, + requestAttemptCount: attempt.requestAttemptCount + 1, + retryState: attempt.reactiveRectifierRetryState, + }); + } catch (retryLaunchError) { + lastError = + retryLaunchError instanceof Error + ? retryLaunchError + : new Error(String(retryLaunchError)); + lastErrorCategory = await categorizeErrorAsync(lastError); + await settleFailure( + ProxyForwarder.resolveHedgeTerminalError(lastError, lastErrorCategory) + ); + } + return; + } + + attempt.pending = false; + const failureAction = coordinator.markFailed(id); session.addProviderToChain(provider, { ...attempt.endpointAudit, reason: "retry_failed", attemptNumber: attempt.sequence, statusCode: lastError instanceof ProxyError ? lastError.statusCode : undefined, - errorMessage: lastError.message, + errorMessage, }); if ( lastErrorCategory === ErrorCategory.PROVIDER_ERROR && @@ -5365,7 +5440,10 @@ export class ProxyForwarder { }, totalTimeoutMs); try { - const hasSticky = session.shouldReuseProvider() && !!session.sessionId; + const hasSticky = + session.shouldReuseProvider() && + !!session.sessionId && + bindingSnapshot?.providerId === initialProvider.id; try { await launch(initialProvider, "normal"); } catch (error) { @@ -5399,13 +5477,36 @@ export class ProxyForwarder { sticky.kind = "fallback"; coordinator.promoteToFallback(sticky.id); if (bindingSnapshot && bindingSnapshot.providerId === initialProvider.id) { - void SessionManager.clearVersionedSessionProvider( - bindingSnapshot, - initialProvider.id, - Math.ceil((settings.stickyTimeoutCooldownMs ?? 300_000) / 1000) - ).catch((error) => - logger.debug("[Discovery] Failed to clear timed-out Sticky", { error }) - ); + void (async () => { + const cleared = await SessionManager.clearVersionedSessionProvider( + bindingSnapshot, + initialProvider.id, + Math.ceil((settings.stickyTimeoutCooldownMs ?? 300_000) / 1000) + ); + if (cleared.status === "ok") { + bindingSnapshot = cleared.snapshot; + session.setSessionBindingSnapshot(cleared.snapshot); + } else { + logger.debug("[Discovery] Failed to clear timed-out Sticky", { + reason: cleared.reason, + }); + } + })() + .catch((error) => + logger.debug("[Discovery] Failed to clear timed-out Sticky", { error }) + ) + .finally(() => { + if (currentRound < maxRounds) { + void launchNextRound(Math.max(0, concurrency - 1)).catch((error) => + logger.warn("[Discovery] Sticky round launch failed", { error }) + ); + } else { + void onBoundary().catch((error) => + logger.warn("[Discovery] Sticky boundary failed", { error }) + ); + } + }); + return; } if (currentRound < maxRounds) { void launchNextRound(Math.max(0, concurrency - 1)).catch((error) => From 24a39ccdce21e97fe4407e88dbbe59c27d56a030 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 16:52:01 -0400 Subject: [PATCH 17/32] style(discovery): organize response handler imports --- src/app/v1/_lib/proxy/response-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 3b7ee3d7f..b1e13938d 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -13,9 +13,9 @@ import { requestCloudPriceTableSync } from "@/lib/price-sync/cloud-price-updater import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { RateLimitService } from "@/lib/rate-limit"; import { deleteLiveChain } from "@/lib/redis/live-chain-store"; +import type { SessionBindingSnapshot } from "@/lib/redis/session-binding"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; -import type { SessionBindingSnapshot } from "@/lib/redis/session-binding"; import { CODEX_1M_CONTEXT_TOKEN_THRESHOLD } from "@/lib/special-attributes"; import type { CostBreakdown, From e3cb570ca89c79bd70d76419815a5acb7b230a7a Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 19:06:56 -0400 Subject: [PATCH 18/32] fix(settings): tighten discovery configuration --- messages/zh-TW/settings/config.json | 4 +- .../_components/system-settings-form.tsx | 69 +++++++++++-------- src/lib/api/v1/schemas/system-config.ts | 2 +- src/lib/validation/schemas.ts | 2 +- .../system-settings-discovery.test.ts | 5 ++ 5 files changed, 48 insertions(+), 34 deletions(-) diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 3e4a4b2dc..8ccc6318a 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -124,8 +124,8 @@ "discoveryEnabledDesc": "啟用後,冷啟動串流請求會在限定視窗內探測多個供應商,並且最多保留一個保底請求。預設關閉。", "discoveryConcurrency": "Discovery 首輪並發數", "maxDiscoveryRounds": "Discovery 最大輪數", - "discoverySlaMs": "Discovery SLA(毫秒)", - "stickySlaMs": "Sticky SLA(毫秒)", + "discoverySlaMs": "Discovery 每輪 SLA(毫秒)", + "stickySlaMs": "Sticky 等待 SLA(毫秒)", "racingTotalTimeoutMs": "Discovery 總逾時(毫秒)", "stickyTimeoutCooldownMs": "Sticky 逾時冷卻(毫秒)", "discoveryWindowDesc": "總逾時必須不小於 Sticky SLA + 最大輪數 × Discovery SLA。Discovery 輸家會取消,不走舊 Hedge 的 drain 或輸家計費。", 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 2642cbf8a..c887a6d2f 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -719,37 +719,46 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) disabled={isPending} />
-
- {( - [ - ["discoveryConcurrency", discoveryConcurrency, setDiscoveryConcurrency, 1], - ["maxDiscoveryRounds", maxDiscoveryRounds, setMaxDiscoveryRounds, 1], - ["discoverySlaMs", discoverySlaMs, setDiscoverySlaMs, 1], - ["stickySlaMs", stickySlaMs, setStickySlaMs, 1], - ["racingTotalTimeoutMs", racingTotalTimeoutMs, setRacingTotalTimeoutMs, 1], - ["stickyTimeoutCooldownMs", stickyTimeoutCooldownMs, setStickyTimeoutCooldownMs, 1], - ] as const - ).map(([key, value, setter, min]) => ( -
- - - setter(event.target.value === "" ? "" : Number(event.target.value)) - } - disabled={isPending || !discoveryEnabled} - className={inputClassName} - /> + {discoveryEnabled ? ( + <> +
+ {( + [ + ["discoveryConcurrency", discoveryConcurrency, setDiscoveryConcurrency, 2], + ["maxDiscoveryRounds", maxDiscoveryRounds, setMaxDiscoveryRounds, 1], + ["discoverySlaMs", discoverySlaMs, setDiscoverySlaMs, 1], + ["stickySlaMs", stickySlaMs, setStickySlaMs, 1], + ["racingTotalTimeoutMs", racingTotalTimeoutMs, setRacingTotalTimeoutMs, 1], + [ + "stickyTimeoutCooldownMs", + stickyTimeoutCooldownMs, + setStickyTimeoutCooldownMs, + 1, + ], + ] as const + ).map(([key, value, setter, min]) => ( +
+ + + setter(event.target.value === "" ? "" : Number(event.target.value)) + } + disabled={isPending} + className={inputClassName} + /> +
+ ))}
- ))} -
-

{t("discoveryWindowDesc")}

+

{t("discoveryWindowDesc")}

+ + ) : null}
{/* Verbose Provider Error */} diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index cdd5d7a12..977014298 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -102,7 +102,7 @@ export const SystemSettingsSchema = z discoveryConcurrency: z .number() .int() - .positive() + .min(2) .describe("Maximum number of normal Discovery attempts in the initial batch."), maxDiscoveryRounds: z.number().int().positive().describe("Maximum number of Discovery rounds."), discoverySlaMs: z diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index f347fc7ba..66bd6741c 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -1011,7 +1011,7 @@ export const UpdateSystemSettingsSchema = z discoveryConcurrency: z.coerce .number() .int("Discovery 并发数必须是整数") - .min(1, "Discovery 并发数必须大于 0") + .min(2, "Discovery 并发数不能小于 2") .max(32, "Discovery 并发数不能超过 32") .optional(), maxDiscoveryRounds: z.coerce diff --git a/tests/unit/validation/system-settings-discovery.test.ts b/tests/unit/validation/system-settings-discovery.test.ts index 1950ef225..8c831ddcf 100644 --- a/tests/unit/validation/system-settings-discovery.test.ts +++ b/tests/unit/validation/system-settings-discovery.test.ts @@ -15,6 +15,11 @@ describe("UpdateSystemSettingsSchema Discovery settings", () => { 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); + }); + it("rejects a total deadline shorter than the configured discovery window", () => { expect(() => UpdateSystemSettingsSchema.parse({ From 3d314e3279ab878d0e4e28bd0cc9fc48e78c5ad8 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 20:22:03 -0400 Subject: [PATCH 19/32] fix(discovery): preserve long-stream binding safety --- .../_components/system-settings-form.tsx | 3 +- src/app/v1/_lib/proxy/response-handler.ts | 39 ++++- .../integration/proxy-hedge-lifecycle.test.ts | 12 ++ ...esponse-handler-client-abort-drain.test.ts | 8 ++ ...handler-endpoint-circuit-isolation.test.ts | 135 ++++++++++++++++++ 5 files changed, 193 insertions(+), 4 deletions(-) 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 c887a6d2f..f43859f32 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -265,7 +265,8 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) }; if ( discoveryEnabled && - Object.values(discoveryConfig).some((value) => !Number.isSafeInteger(value) || value < 1) + (discoveryConfig.discoveryConcurrency < 2 || + Object.values(discoveryConfig).some((value) => !Number.isSafeInteger(value) || value < 1)) ) { toast.error(t("discoveryWindowInvalid")); return; diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index a9ca3a7de..eee1262a2 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -114,7 +114,8 @@ function isSessionBindingMutationAllowed(session: ProxySession): boolean { } function startDiscoveryLeaseLifecycle(session: ProxySession): DiscoveryLeaseLifecycle { - const lease = peekDeferredStreamingFinalization(session)?.discoveryLease; + const deferred = peekDeferredStreamingFinalization(session); + const lease = deferred?.discoveryLease; if (!lease) { return { active: false, @@ -127,6 +128,12 @@ function startDiscoveryLeaseLifecycle(session: ProxySession): DiscoveryLeaseLife let renewalInFlight: Promise | null = null; let releasePromise: Promise | null = null; let ownershipState: "unknown" | "owned" | "lost" = "unknown"; + const bindingSnapshot = + (deferred?.bindingIntent === "create" || deferred?.bindingIntent === "renew") && + deferred.bindingSnapshot?.sessionId === lease.sessionId && + deferred.bindingSnapshot.keyId === lease.keyId + ? deferred.bindingSnapshot + : null; const stopRenewal = () => { if (renewalTimer) { @@ -157,6 +164,25 @@ function startDiscoveryLeaseLifecycle(session: ProxySession): DiscoveryLeaseLife }); return false; } + + if (bindingSnapshot) { + const touched = await SessionManager.touchVersionedSessionBinding(bindingSnapshot); + if ( + touched.status !== "ok" || + touched.snapshot.generation !== bindingSnapshot.generation || + touched.snapshot.providerId !== bindingSnapshot.providerId + ) { + ownershipState = "lost"; + stopRenewal(); + logger.warn("[ResponseHandler] Discovery binding heartbeat stopped", { + sessionId: lease.sessionId, + keyId: lease.keyId, + status: touched.status, + reason: "reason" in touched ? touched.reason : "snapshot_mismatch", + }); + return false; + } + } ownershipState = "owned"; return true; })() @@ -181,7 +207,14 @@ function startDiscoveryLeaseLifecycle(session: ProxySession): DiscoveryLeaseLife // the downstream response. Renew immediately so an expired/lost token is // observed before any terminal Session binding mutation is attempted. const handoffRenewal = renew(); - const renewalIntervalMs = Math.max(250, Math.floor((lease.ttlSeconds * 1000) / 3)); + const leaseRenewalIntervalMs = Math.floor((lease.ttlSeconds * 1000) / 3); + const bindingRefreshIntervalMs = bindingSnapshot + ? SessionManager.getVersionedSessionBindingRefreshIntervalMs() + : Number.POSITIVE_INFINITY; + const renewalIntervalMs = Math.max( + 250, + Math.min(leaseRenewalIntervalMs, bindingRefreshIntervalMs) + ); renewalTimer = setInterval(() => { void renew(); }, renewalIntervalMs); @@ -1174,7 +1207,7 @@ function hasStreamCompletionMarker(text: string, format: ProxySession["originalF case "claude": return events.some( (event) => - event.event === "message_stop" && + (event.event === "message_stop" || event.event === "message") && isRecord(event.data) && event.data.type === "message_stop" ); diff --git a/tests/integration/proxy-hedge-lifecycle.test.ts b/tests/integration/proxy-hedge-lifecycle.test.ts index 0f2b6b3b0..7707cabb6 100644 --- a/tests/integration/proxy-hedge-lifecycle.test.ts +++ b/tests/integration/proxy-hedge-lifecycle.test.ts @@ -8,6 +8,7 @@ import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { type MessageContext, ProxySession } from "@/app/v1/_lib/proxy/session"; import { DbPoolAdmissionError } from "@/drizzle/admitted-client"; import { getGlobalAgentPool, resetGlobalAgentPool } from "@/lib/proxy-agent"; +import type { SessionBindingSnapshot } from "@/lib/redis/session-binding"; import type { Key } from "@/types/key"; import type { Provider } from "@/types/provider"; import type { User } from "@/types/user"; @@ -125,6 +126,17 @@ vi.mock("@/lib/session-manager", async (importOriginal) => { static override async renewSessionDiscoveryLease() { return state.renewDiscoveryLease(); } + static override getVersionedSessionBindingRefreshIntervalMs() { + return 100_000; + } + static override async touchVersionedSessionBinding(snapshot: SessionBindingSnapshot) { + return { + status: "ok" as const, + source: "touched" as const, + snapshot, + legacyFallbackAllowed: false as const, + }; + } static override async releaseSessionDiscoveryLease() { return state.releaseDiscoveryLease(); } diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts index ded21ba4b..55c8ff3b2 100644 --- a/tests/unit/proxy/response-handler-client-abort-drain.test.ts +++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts @@ -10,6 +10,7 @@ import { AsyncTaskManager, shutdownAllAsyncTasks } from "@/lib/async-task-manage import { recordFailure } from "@/lib/circuit-breaker"; import { emitProxyLangfuseTrace } from "@/lib/langfuse/emit-proxy-trace"; import { RateLimitService } from "@/lib/rate-limit"; +import type { SessionBindingSnapshot } from "@/lib/redis/session-binding"; import { SessionManager } from "@/lib/session-manager"; import { updateMessageRequestCostWithBreakdown, @@ -118,6 +119,7 @@ vi.mock("@/lib/session-manager", () => ({ clearVersionedSessionProvider: vi.fn(), compareAndSetSessionProvider: vi.fn(), getSessionBindingSnapshot: vi.fn(), + getVersionedSessionBindingRefreshIntervalMs: vi.fn(() => 100_000), renewSessionDiscoveryLease: vi.fn(async () => ({ status: "renewed", legacyFallbackAllowed: false, @@ -126,6 +128,12 @@ vi.mock("@/lib/session-manager", () => ({ status: "released", legacyFallbackAllowed: false, })), + touchVersionedSessionBinding: vi.fn(async (snapshot: SessionBindingSnapshot) => ({ + status: "ok", + source: "touched", + snapshot, + legacyFallbackAllowed: false, + })), extractCodexPromptCacheKey: vi.fn(), storeSessionResponse: vi.fn(async () => undefined), storeSessionRequestPhaseSnapshot: vi.fn(), 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 038c9378c..779241a8f 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -80,8 +80,10 @@ vi.mock("@/lib/session-manager", () => ({ clearVersionedSessionProvider: vi.fn(), compareAndSetSessionProvider: vi.fn(), getSessionBindingSnapshot: vi.fn(), + getVersionedSessionBindingRefreshIntervalMs: vi.fn(), renewSessionDiscoveryLease: vi.fn(), releaseSessionDiscoveryLease: vi.fn(), + touchVersionedSessionBinding: vi.fn(), extractCodexPromptCacheKey: vi.fn(), updateSessionBindingSmart: vi.fn(), updateSessionProvider: vi.fn(), @@ -468,10 +470,17 @@ function setupCommonMocks() { }, legacyFallbackAllowed: false, }); + vi.mocked(SessionManager.getVersionedSessionBindingRefreshIntervalMs).mockReturnValue(100_000); vi.mocked(SessionManager.renewSessionDiscoveryLease).mockResolvedValue({ status: "renewed", legacyFallbackAllowed: false, }); + vi.mocked(SessionManager.touchVersionedSessionBinding).mockImplementation(async (binding) => ({ + status: "ok", + source: "touched", + snapshot: binding, + legacyFallbackAllowed: false, + })); vi.mocked(SessionManager.releaseSessionDiscoveryLease).mockResolvedValue({ status: "released", legacyFallbackAllowed: false, @@ -845,6 +854,11 @@ describe("Endpoint circuit breaker isolation", () => { }); 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, @@ -915,6 +929,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 () => { @@ -1165,6 +1180,126 @@ describe("Endpoint circuit breaker isolation", () => { } }); + it("touches the captured binding often enough for a long Discovery winner", async () => { + vi.useFakeTimers(); + try { + const session = createSession(); + const snapshot = { + sessionId: "fake-session", + keyId: 456, + providerId: null, + generation: "long-stream-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, + discoveryLease: { + sessionId: "fake-session", + keyId: 456, + ownerToken: "long-stream-owner", + ttlSeconds: 3_600, + }, + }); + vi.mocked(SessionManager.getVersionedSessionBindingRefreshIntervalMs).mockReturnValue(1_000); + const controlled = createControllableSuccessStreamResponse(); + + const clientResponse = await ProxyResponseHandler.dispatch(session, controlled.response); + await vi.advanceTimersByTimeAsync(3_000); + expect(SessionManager.touchVersionedSessionBinding).toHaveBeenCalledTimes(4); + expect(SessionManager.touchVersionedSessionBinding).toHaveBeenLastCalledWith(snapshot); + + controlled.complete(); + await clientResponse.text(); + await drainAsyncTasks(); + + expect(SessionManager.compareAndSetSessionProvider).toHaveBeenCalledWith(snapshot, 1); + expect(SessionManager.getSessionBindingSnapshot).not.toHaveBeenCalled(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( + "fake-session", + 456, + "long-stream-owner" + ); + } finally { + vi.useRealTimers(); + } + }); + + it("does not revive a binding after an authority advances its generation", async () => { + vi.useFakeTimers(); + try { + const session = createSession(); + const snapshot = { + sessionId: "fake-session", + keyId: 456, + providerId: 1, + generation: "generation-before-termination", + } as const; + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "test-provider", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.com", + upstreamStatusCode: 200, + bindingIntent: "renew", + bindingSnapshot: snapshot, + requiresCompletionMarker: true, + discoveryLease: { + sessionId: "fake-session", + keyId: 456, + ownerToken: "terminated-stream-owner", + ttlSeconds: 3_600, + }, + }); + vi.mocked(SessionManager.getVersionedSessionBindingRefreshIntervalMs).mockReturnValue(1_000); + vi.mocked(SessionManager.touchVersionedSessionBinding) + .mockResolvedValueOnce({ + status: "ok", + source: "touched", + snapshot, + legacyFallbackAllowed: false, + }) + .mockResolvedValueOnce({ + status: "conflict", + reason: "generation_mismatch", + legacyFallbackAllowed: false, + }); + const controlled = createControllableSuccessStreamResponse(); + + const clientResponse = await ProxyResponseHandler.dispatch(session, controlled.response); + await vi.advanceTimersByTimeAsync(1_000); + controlled.complete(); + await clientResponse.text(); + await drainAsyncTasks(); + + expect(SessionManager.touchVersionedSessionBinding).toHaveBeenCalledTimes(2); + expect(SessionManager.compareAndSetSessionProvider).not.toHaveBeenCalled(); + expect(SessionManager.getSessionBindingSnapshot).not.toHaveBeenCalled(); + expect(SessionManager.releaseSessionDiscoveryLease).toHaveBeenCalledWith( + "fake-session", + 456, + "terminated-stream-owner" + ); + } finally { + vi.useRealTimers(); + } + }); + it("does not delay downstream delivery while the lease handoff renewal is pending", async () => { const handoffRenewal = Promise.withResolvers<{ status: "renewed"; From 825f4ff380cdd301dd168caa21d1a34473012216 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 20:42:27 -0400 Subject: [PATCH 20/32] fix(discovery): fail closed on binding conflicts --- src/app/v1/_lib/proxy/forwarder.ts | 10 ++++-- src/app/v1/_lib/proxy/response-handler.ts | 14 ++++---- .../proxy-forwarder-hedge-first-byte.test.ts | 33 +++++++++++++------ ...handler-endpoint-circuit-isolation.test.ts | 13 ++++++++ 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 1f1489c00..3012b1907 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3945,9 +3945,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; diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index eee1262a2..aa66e78c2 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -1197,13 +1197,13 @@ function hasStreamCompletionMarker(text: string, format: ProxySession["originalF switch (format) { case "response": - return events.some( - (event) => - event.event === "response.completed" && - isRecord(event.data) && - event.data.type === "response.completed" && - isRecord(event.data.response) - ); + 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) => 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 7bbcc1856..91be7ff5d 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -168,6 +168,7 @@ import { import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; import { ModelRedirector } from "@/app/v1/_lib/proxy/model-redirector"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { peekDeferredStreamingFinalization } from "@/app/v1/_lib/proxy/stream-finalization"; import { DbPoolAdmissionError } from "@/drizzle/admitted-client"; import { logger } from "@/lib/logger"; import type { Provider } from "@/types/provider"; @@ -2362,8 +2363,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, @@ -2378,6 +2380,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 { @@ -2385,16 +2388,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(); }); 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 779241a8f..e076c2d8f 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -867,6 +867,19 @@ describe("Endpoint circuit breaker isolation", () => { response: { id: "resp_completed" }, })}\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", format: "openai" as const, From 811192a10baeb3a0d1b7f3d6a07bf95c2788fecc Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 20:57:19 -0400 Subject: [PATCH 21/32] fix(discovery): reject failed response terminal markers --- src/app/v1/_lib/proxy/discovery-validity.ts | 26 ++++++++- src/app/v1/_lib/proxy/response-handler.ts | 2 + tests/unit/proxy/discovery-validity.test.ts | 15 ++++++ ...handler-endpoint-circuit-isolation.test.ts | 53 +++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/app/v1/_lib/proxy/discovery-validity.ts b/src/app/v1/_lib/proxy/discovery-validity.ts index 059bde293..81a16885d 100644 --- a/src/app/v1/_lib/proxy/discovery-validity.ts +++ b/src/app/v1/_lib/proxy/discovery-validity.ts @@ -49,15 +49,37 @@ function hasAnthropicContentBlock(value: unknown): boolean { return block.type === "text" ? hasContent(block.text) : true; } -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") { diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index aa66e78c2..14b532cef 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 { @@ -1197,6 +1198,7 @@ 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; diff --git a/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index 5093298dd..46ee321c2 100644 --- a/tests/unit/proxy/discovery-validity.test.ts +++ b/tests/unit/proxy/discovery-validity.test.ts @@ -33,6 +33,21 @@ describe("discovery validity", () => { 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/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index e076c2d8f..5ddb7a2f9 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -853,6 +853,59 @@ 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: "STREAM_COMPLETION_MARKER_MISSING" }) + ); + }); + it.each([ { label: "Claude data-only stop", From 088061992968c39ee78116af91c52571b8a8a7f8 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 22:17:52 -0400 Subject: [PATCH 22/32] fix(discovery): unwrap Gemini response candidates --- src/app/v1/_lib/proxy/discovery-validity.ts | 7 ++++++- tests/unit/proxy/discovery-validity.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/app/v1/_lib/proxy/discovery-validity.ts b/src/app/v1/_lib/proxy/discovery-validity.ts index 7686c6bf6..3e67cc523 100644 --- a/src/app/v1/_lib/proxy/discovery-validity.ts +++ b/src/app/v1/_lib/proxy/discovery-validity.ts @@ -143,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/tests/unit/proxy/discovery-validity.test.ts b/tests/unit/proxy/discovery-validity.test.ts index 76c8cc192..00dccd2f8 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", "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("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); From bde3d9f206854a25e32816104295062841b255a3 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Mon, 20 Jul 2026 22:54:51 -0400 Subject: [PATCH 23/32] fix(discovery): localize validation and completion checks --- messages/en/settings/config.json | 1 + messages/ja/settings/config.json | 1 + messages/ru/settings/config.json | 1 + messages/zh-CN/settings/config.json | 1 + messages/zh-TW/settings/config.json | 1 + src/actions/system-config.ts | 25 ++++++-- .../_components/system-settings-form.tsx | 57 ++++++++++++++----- src/app/api/v1/resources/system/handlers.ts | 5 +- src/app/api/v1/resources/system/router.ts | 9 ++- src/app/v1/_lib/proxy/response-handler.ts | 6 +- src/lib/api/v1/_shared/error-envelope.ts | 4 +- src/lib/api/v1/_shared/request-body.ts | 9 ++- src/lib/api/v1/schemas/system-config.ts | 38 +++++++++---- src/lib/validation/discovery-settings.ts | 28 +++++++++ src/lib/validation/schemas.ts | 48 +++++++++------- tests/api/v1/system/system-config.test.ts | 15 +++++ tests/unit/actions/system-config-save.test.ts | 27 +++++++++ ...handler-endpoint-circuit-isolation.test.ts | 7 +++ .../system-settings-discovery.test.ts | 13 ++++- 19 files changed, 239 insertions(+), 57 deletions(-) create mode 100644 src/lib/validation/discovery-settings.ts diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index ad2fe21e8..149408b5e 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -128,6 +128,7 @@ "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 b7ca0000b..e49d28c1b 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -130,6 +130,7 @@ "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 108a2e4f8..9624cf448 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -130,6 +130,7 @@ "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 b3249e8dd..c4a94ea93 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -57,6 +57,7 @@ "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 8ccc6318a..613494c92 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -130,6 +130,7 @@ "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 40e46320b..4ffbdf87c 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -1,6 +1,7 @@ "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"; @@ -15,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"; @@ -26,7 +31,10 @@ import type { } from "@/types/system-config"; import type { ActionResult } from "./types"; -const DISCOVERY_WINDOW_INVALID = "DISCOVERY_WINDOW_INVALID"; +function discoveryValidationErrorCode(error: unknown): string | null { + if (!(error instanceof ZodError)) return null; + return getDiscoveryValidationErrorCode(error.issues) ?? null; +} export async function fetchSystemSettings(): Promise> { try { @@ -141,7 +149,7 @@ export async function saveSystemSettings(formData: { return { ok: false, error: "Discovery window validation failed.", - errorCode: DISCOVERY_WINDOW_INVALID, + errorCode: DISCOVERY_WINDOW_INVALID_ERROR_CODE, }; } const updated = await updateSystemSettings({ @@ -280,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", @@ -290,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 f43859f32..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, @@ -263,12 +264,12 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) racingTotalTimeoutMs: Number(racingTotalTimeoutMs), stickyTimeoutCooldownMs: Number(stickyTimeoutCooldownMs), }; - if ( - discoveryEnabled && - (discoveryConfig.discoveryConcurrency < 2 || - Object.values(discoveryConfig).some((value) => !Number.isSafeInteger(value) || value < 1)) - ) { - toast.error(t("discoveryWindowInvalid")); + 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 ( @@ -397,7 +398,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) const errorMessage = result.errorCode === "DISCOVERY_WINDOW_INVALID" ? t("discoveryWindowInvalid") - : result.error || t("saveFailed"); + : result.errorCode === "DISCOVERY_SETTINGS_INVALID" + ? t("discoverySettingsInvalid") + : result.error || t("saveFailed"); toast.error(errorMessage); return; } @@ -725,19 +728,44 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
{( [ - ["discoveryConcurrency", discoveryConcurrency, setDiscoveryConcurrency, 2], - ["maxDiscoveryRounds", maxDiscoveryRounds, setMaxDiscoveryRounds, 1], - ["discoverySlaMs", discoverySlaMs, setDiscoverySlaMs, 1], - ["stickySlaMs", stickySlaMs, setStickySlaMs, 1], - ["racingTotalTimeoutMs", racingTotalTimeoutMs, setRacingTotalTimeoutMs, 1], + [ + "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, - 1, + ...DISCOVERY_FIELD_LIMITS.stickyTimeoutCooldownMs, ], ] as const - ).map(([key, value, setter, min]) => ( + ).map(([key, value, setter, min, max]) => (