From 36bacbbd69d055e4a40da710b4bad7e8b728f0a4 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:42:07 +0800 Subject: [PATCH 1/2] Upstream throttling: usage limits, wait settings, throttle state - Upstream dialog: Usage limits under the concurrency limit, reusing the key limits editor; minute and hour periods take "every N". Gateway keys get every N too. Share links and exports carry the limits. - Settings > Failover: "Wait at most" (failover.wait_secs, default 120 s) replaces the free-slot wait. - Upstreams page: a live countdown tag while an upstream is rate limited or at a usage limit, and each limit's usage on hover. - Traffic: queued requests show what they wait for until request_dequeued; the route view shows 429 pauses and the new skip reasons. - zh for the new core message codes. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/overview.json | 9 +- scripts/shots/core/en/security.json | 219 +++++++++++++++++-- scripts/shots/core/en/status.json | 4 +- scripts/shots/core/zh/overview.json | 9 +- scripts/shots/core/zh/security.json | 219 +++++++++++++++++-- scripts/shots/core/zh/status.json | 4 +- src-tauri/src/clients/desktop_rule.rs | 2 +- src-tauri/src/import_link.rs | 147 ++++++++++++- src-tauri/src/import_link/tests.rs | 97 +++++++- src-tauri/src/notices/fixtures/snapshot.json | 3 +- src-tauri/src/notices/tests.rs | 1 + src-tauri/src/wire.rs | 14 ++ src-tauri/tests/control_plane.rs | 2 + src/events.test.ts | 110 ++++++++++ src/generated/lite-api.ts | 16 +- src/generated/tw-api.ts | 96 ++++++-- src/i18n/core.zh.cases.json | 39 ++++ src/i18n/core.zh.json | 21 ++ src/import/services.test.ts | 1 + src/keys/LimitsEditor.i18n.ts | 5 + src/keys/LimitsEditor.tsx | 76 +++++-- src/keys/limits.i18n.ts | 18 +- src/keys/limits.test.ts | 64 +++++- src/keys/limits.ts | 109 ++++++--- src/labels.i18n.ts | 8 +- src/labels.test.ts | 24 ++ src/labels.ts | 4 + src/requestRouting.test.ts | 34 +++ src/requestRouting.ts | 61 +++++- src/requestTable.test.ts | 16 +- src/requestTable.ts | 8 + src/settings/FailoverSection.i18n.ts | 14 +- src/settings/FailoverSection.tsx | 17 +- src/settings/failover.test.ts | 12 +- src/traffic/RequestTable.tsx | 17 +- src/traffic/Traffic.i18n.tsx | 8 + src/traffic/cells.tsx | 6 +- src/traffic/drawer/RequestDrawer.i18n.tsx | 27 +++ src/traffic/drawer/Routing.tsx | 12 +- src/types.ts | 24 ++ src/upstreams/ChatgptAccountSection.tsx | 3 + src/upstreams/ConnectionSection.i18n.ts | 2 + src/upstreams/ConnectionSection.tsx | 19 +- src/upstreams/LimitsField.i18n.ts | 12 + src/upstreams/LimitsField.tsx | 40 ++++ src/upstreams/ShareDialog.i18n.ts | 2 + src/upstreams/ShareDialog.tsx | 2 + src/upstreams/ThrottleTag.tsx | 81 +++++++ src/upstreams/UpstreamDialog.tsx | 21 +- src/upstreams/UpstreamTable.tsx | 28 ++- src/upstreams/UpstreamsPage.tsx | 7 +- src/upstreams/data.ts | 51 ++++- src/upstreams/importForm.test.ts | 10 + src/upstreams/labels.i18n.ts | 9 + src/upstreams/labels.ts | 6 + src/upstreams/share.test.ts | 28 +++ src/upstreams/share.ts | 9 + src/upstreams/throttle.i18n.ts | 52 +++++ src/upstreams/throttle.test.ts | 128 +++++++++++ src/upstreams/throttle.ts | 129 +++++++++++ src/upstreams/upstreamForm.i18n.ts | 4 + src/upstreams/upstreamForm.test.ts | 42 ++++ src/upstreams/upstreamForm.ts | 21 +- src/useRequests.ts | 4 + 64 files changed, 2090 insertions(+), 197 deletions(-) create mode 100644 src/upstreams/LimitsField.i18n.ts create mode 100644 src/upstreams/LimitsField.tsx create mode 100644 src/upstreams/ThrottleTag.tsx create mode 100644 src/upstreams/throttle.i18n.ts create mode 100644 src/upstreams/throttle.test.ts create mode 100644 src/upstreams/throttle.ts diff --git a/scripts/shots/core/en/overview.json b/scripts/shots/core/en/overview.json index 6affe4b0..1f94404d 100644 --- a/scripts/shots/core/en/overview.json +++ b/scripts/shots/core/en/overview.json @@ -72,7 +72,7 @@ "pause_secs": 60, "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, - "slot_wait_secs": 30 + "wait_secs": 120 }, "groups": [ { @@ -152,6 +152,7 @@ "forward_client_identity": false, "health": "ok", "key": "sk-anEXAMPLEQ3xA", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -191,6 +192,7 @@ "forward_client_identity": false, "health": "ok", "key": "sk-rlEXAMPLEGa7e", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -224,6 +226,7 @@ "disabled": false, "forward_client_identity": false, "health": "ok", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -265,6 +268,7 @@ "forward_client_identity": false, "health": "ok", "key": "${OPENROUTER_API_KEY}", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -300,6 +304,7 @@ "forward_client_identity": false, "health": "ok", "key": "sk-3fEXAMPLE9f2c", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -331,6 +336,7 @@ "forward_client_identity": false, "health": "ok", "key": "${GEMINI_API_KEY}", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -366,6 +372,7 @@ "disabled": false, "forward_client_identity": false, "health": "ok", + "limits": [], "model_count": 1, "model_fetching": false, "model_source": "manual", diff --git a/scripts/shots/core/en/security.json b/scripts/shots/core/en/security.json index 0680efb5..70af8a0b 100644 --- a/scripts/shots/core/en/security.json +++ b/scripts/shots/core/en/security.json @@ -17,6 +17,10 @@ }, "name": "Unicode tag characters", "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ], "why": "Entirely invisible in an editor, yet carried into the model's context as they are, so they can hide a whole instruction." }, { @@ -35,6 +39,10 @@ }, "name": "Bidirectional controls", "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ], "why": "They can make the order shown on screen differ from the actual order of the characters." }, { @@ -54,6 +62,10 @@ }, "name": "Zero-width characters", "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ], "why": "Invisible in an editor, and read by the model. Emoji, Persian and other ordinary writing use them too." }, { @@ -73,6 +85,10 @@ }, "name": "Private-use characters", "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ], "why": "They have no standard meaning. Some icon fonts use them." }, { @@ -87,7 +103,11 @@ "text": "ignore previous instructions" }, "name": "Ignore previous instructions", - "on_by_default": true + "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -101,7 +121,11 @@ "text": "ignore all previous" }, "name": "Ignore all previous", - "on_by_default": true + "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -115,7 +139,11 @@ "text": "disregard your instructions" }, "name": "Disregard your instructions", - "on_by_default": true + "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -129,7 +157,11 @@ "text": "jailbreak" }, "name": "Jailbreak", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -143,7 +175,11 @@ "text": " dan " }, "name": "DAN", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -157,7 +193,11 @@ "text": "developer mode" }, "name": "Developer mode", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -171,7 +211,11 @@ "text": "you are now" }, "name": "Persona manipulation", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -185,7 +229,11 @@ "text": "new persona" }, "name": "New persona", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -199,7 +247,11 @@ "text": "act as" }, "name": "Act as", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -213,7 +265,11 @@ "text": "pretend to be" }, "name": "Pretend to be", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -227,7 +283,11 @@ "text": "system prompt" }, "name": "System prompt extraction", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -241,7 +301,11 @@ "text": "reveal your instructions" }, "name": "Reveal instructions", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -255,7 +319,11 @@ "text": "what are your rules" }, "name": "What are your rules", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -269,7 +337,11 @@ "pattern": "[A-Za-z0-9+/=]{50,}" }, "name": "Base64 smuggling", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -283,7 +355,11 @@ "text": "忽略之前" }, "name": "Ignore previous instructions (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -297,7 +373,11 @@ "text": "忘记你" }, "name": "Forget your instructions (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -311,7 +391,11 @@ "text": "不要遵循" }, "name": "Do not follow (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -325,7 +409,11 @@ "text": "现在你是" }, "name": "You are now (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -339,7 +427,11 @@ "text": "扮演" }, "name": "Role-play (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -353,7 +445,11 @@ "text": "透露你的" }, "name": "Reveal your instructions (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -367,7 +463,11 @@ "text": "系统提示词" }, "name": "System prompt (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -381,7 +481,11 @@ "text": "越狱" }, "name": "Jailbreak (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] } ] }, @@ -441,6 +545,7 @@ "id": "exfil-credentials", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "(\\.ssh/id_|\\.aws/credentials|\\.netrc|/\\.env)[^\\n]{0,30}(发|传|上报|打印|输出|贴出|读出|(?i:send|upload|post|print|paste|exfil))" }, @@ -456,6 +561,7 @@ "id": "exfil-credentials-reversed", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "(发送|上传|贴出|打印|输出|读取|(?i:send|upload|post|print|read))[^\\n]{0,30}(\\.ssh/id_|\\.aws/credentials|\\.netrc|/\\.env)" }, @@ -516,6 +622,7 @@ "id": "write-startup-item", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "(>>?|tee|cp|mv|ln\\s+-s)[^\\n]{0,200}(\\.zshrc|\\.bashrc|\\.bash_profile|\\.profile|\\.zprofile|LaunchAgents|LaunchDaemons|\\.git/hooks|crontab)" }, @@ -531,6 +638,7 @@ "id": "crontab-install", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "crontab\\s+(-r\\b|-\\s*($|[\"';&|)])|[^\\s-][^\\n]{0,40})" }, @@ -546,6 +654,7 @@ "id": "rm-rf-root", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "rm\\s+(-[a-zA-Z]*\\s+)*-[a-zA-Z]*[rf][a-zA-Z]*\\s+(/|~|\\$HOME)(\\s|$|[\"';&|)])" }, @@ -561,6 +670,7 @@ "id": "chmod-777", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "chmod\\s+(-R\\s+)?777" }, @@ -589,7 +699,9 @@ "mode": "enforce", "rules": [ { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "anthropic-api-key", "kind": "api-keys", @@ -603,7 +715,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "openai-project-key", "kind": "api-keys", @@ -617,7 +731,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "openai-api-key", "kind": "api-keys", @@ -630,7 +746,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-personal-token", "kind": "api-keys", @@ -644,7 +762,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-oauth-token", "kind": "api-keys", @@ -658,7 +778,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-server-token", "kind": "api-keys", @@ -672,7 +794,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-user-token", "kind": "api-keys", @@ -686,7 +810,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-fine-grained-token", "kind": "api-keys", @@ -700,7 +826,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "slack-bot-token", "kind": "api-keys", @@ -714,7 +842,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "slack-user-token", "kind": "api-keys", @@ -728,7 +858,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "slack-app-token", "kind": "api-keys", @@ -742,7 +874,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "aws-access-key-id", "kind": "api-keys", @@ -756,7 +890,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "aws-temporary-key-id", "kind": "api-keys", @@ -770,7 +906,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "google-api-key", "kind": "api-keys", @@ -784,7 +922,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "google-oauth-token", "kind": "api-keys", @@ -798,7 +938,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "gitlab-token", "kind": "api-keys", @@ -812,7 +954,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "stripe-live-key", "kind": "api-keys", @@ -826,7 +970,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "stripe-restricted-key", "kind": "api-keys", @@ -840,7 +986,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "npm-token", "kind": "api-keys", @@ -854,7 +1002,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "digitalocean-token", "kind": "api-keys", @@ -868,7 +1018,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "sendgrid-key", "kind": "api-keys", @@ -882,7 +1034,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "private-key", "kind": "private-keys", @@ -894,7 +1048,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "jwt", "kind": "jwt", @@ -906,7 +1062,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "conn-string-password", "kind": "conn-strings", @@ -918,7 +1076,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "cn-resident-id", "kind": "personal", @@ -931,7 +1091,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "bank-card", "kind": "personal", @@ -1057,7 +1219,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "email", "kind": "personal", @@ -1069,7 +1233,9 @@ "on_by_default": false }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "cn-mobile-phone", "kind": "personal", @@ -1081,7 +1247,9 @@ "on_by_default": false }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "internal-ip", "kind": "internal", @@ -1093,7 +1261,9 @@ "on_by_default": false }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "internal-domain", "kind": "internal", @@ -1110,14 +1280,15 @@ "on_by_default": false }, { + "action": "placeholder", "custom": true, "enabled": true, "id": "customer-id", "kind": "custom", "label": "SECRET", "matcher": { - "kind": "regex", - "pattern": "CUST-\\d{6}" + "kind": "contains", + "text": "CUST-\\d{6}" }, "name": "customer-id", "on_by_default": true diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index ea6a4b92..babbb27b 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 46, + "api_version": 51, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.70.0" + "version": "0.78.0" } diff --git a/scripts/shots/core/zh/overview.json b/scripts/shots/core/zh/overview.json index c3189166..8651aa34 100644 --- a/scripts/shots/core/zh/overview.json +++ b/scripts/shots/core/zh/overview.json @@ -72,7 +72,7 @@ "pause_secs": 60, "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, - "slot_wait_secs": 30 + "wait_secs": 120 }, "groups": [ { @@ -152,6 +152,7 @@ "forward_client_identity": false, "health": "ok", "key": "sk-anEXAMPLEQ3xA", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -191,6 +192,7 @@ "forward_client_identity": false, "health": "ok", "key": "sk-rlEXAMPLEGa7e", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -224,6 +226,7 @@ "disabled": false, "forward_client_identity": false, "health": "ok", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -265,6 +268,7 @@ "forward_client_identity": false, "health": "ok", "key": "${OPENROUTER_API_KEY}", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -300,6 +304,7 @@ "forward_client_identity": false, "health": "ok", "key": "sk-3fEXAMPLE9f2c", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -331,6 +336,7 @@ "forward_client_identity": false, "health": "ok", "key": "${GEMINI_API_KEY}", + "limits": [], "model_count": 0, "model_fetching": false, "model_source": "none", @@ -366,6 +372,7 @@ "disabled": false, "forward_client_identity": false, "health": "ok", + "limits": [], "model_count": 1, "model_fetching": false, "model_source": "manual", diff --git a/scripts/shots/core/zh/security.json b/scripts/shots/core/zh/security.json index 4fa0dceb..ed58177c 100644 --- a/scripts/shots/core/zh/security.json +++ b/scripts/shots/core/zh/security.json @@ -17,6 +17,10 @@ }, "name": "Unicode tag characters", "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ], "why": "Entirely invisible in an editor, yet carried into the model's context as they are, so they can hide a whole instruction." }, { @@ -35,6 +39,10 @@ }, "name": "Bidirectional controls", "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ], "why": "They can make the order shown on screen differ from the actual order of the characters." }, { @@ -54,6 +62,10 @@ }, "name": "Zero-width characters", "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ], "why": "Invisible in an editor, and read by the model. Emoji, Persian and other ordinary writing use them too." }, { @@ -73,6 +85,10 @@ }, "name": "Private-use characters", "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ], "why": "They have no standard meaning. Some icon fonts use them." }, { @@ -87,7 +103,11 @@ "text": "ignore previous instructions" }, "name": "Ignore previous instructions", - "on_by_default": true + "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -101,7 +121,11 @@ "text": "ignore all previous" }, "name": "Ignore all previous", - "on_by_default": true + "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -115,7 +139,11 @@ "text": "disregard your instructions" }, "name": "Disregard your instructions", - "on_by_default": true + "on_by_default": true, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -129,7 +157,11 @@ "text": "jailbreak" }, "name": "Jailbreak", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -143,7 +175,11 @@ "text": " dan " }, "name": "DAN", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -157,7 +193,11 @@ "text": "developer mode" }, "name": "Developer mode", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -171,7 +211,11 @@ "text": "you are now" }, "name": "Persona manipulation", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -185,7 +229,11 @@ "text": "new persona" }, "name": "New persona", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -199,7 +247,11 @@ "text": "act as" }, "name": "Act as", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -213,7 +265,11 @@ "text": "pretend to be" }, "name": "Pretend to be", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -227,7 +283,11 @@ "text": "system prompt" }, "name": "System prompt extraction", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -241,7 +301,11 @@ "text": "reveal your instructions" }, "name": "Reveal instructions", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -255,7 +319,11 @@ "text": "what are your rules" }, "name": "What are your rules", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -269,7 +337,11 @@ "pattern": "[A-Za-z0-9+/=]{50,}" }, "name": "Base64 smuggling", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -283,7 +355,11 @@ "text": "忽略之前" }, "name": "Ignore previous instructions (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -297,7 +373,11 @@ "text": "忘记你" }, "name": "Forget your instructions (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -311,7 +391,11 @@ "text": "不要遵循" }, "name": "Do not follow (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -325,7 +409,11 @@ "text": "现在你是" }, "name": "You are now (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -339,7 +427,11 @@ "text": "扮演" }, "name": "Role-play (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -353,7 +445,11 @@ "text": "透露你的" }, "name": "Reveal your instructions (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "record", @@ -367,7 +463,11 @@ "text": "系统提示词" }, "name": "System prompt (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] }, { "action": "block", @@ -381,7 +481,11 @@ "text": "越狱" }, "name": "Jailbreak (Chinese)", - "on_by_default": false + "on_by_default": false, + "replacement": "***", + "scope": [ + "request" + ] } ] }, @@ -441,6 +545,7 @@ "id": "exfil-credentials", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "(\\.ssh/id_|\\.aws/credentials|\\.netrc|/\\.env)[^\\n]{0,30}(发|传|上报|打印|输出|贴出|读出|(?i:send|upload|post|print|paste|exfil))" }, @@ -456,6 +561,7 @@ "id": "exfil-credentials-reversed", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "(发送|上传|贴出|打印|输出|读取|(?i:send|upload|post|print|read))[^\\n]{0,30}(\\.ssh/id_|\\.aws/credentials|\\.netrc|/\\.env)" }, @@ -516,6 +622,7 @@ "id": "write-startup-item", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "(>>?|tee|cp|mv|ln\\s+-s)[^\\n]{0,200}(\\.zshrc|\\.bashrc|\\.bash_profile|\\.profile|\\.zprofile|LaunchAgents|LaunchDaemons|\\.git/hooks|crontab)" }, @@ -531,6 +638,7 @@ "id": "crontab-install", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "crontab\\s+(-r\\b|-\\s*($|[\"';&|)])|[^\\s-][^\\n]{0,40})" }, @@ -546,6 +654,7 @@ "id": "rm-rf-root", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "rm\\s+(-[a-zA-Z]*\\s+)*-[a-zA-Z]*[rf][a-zA-Z]*\\s+(/|~|\\$HOME)(\\s|$|[\"';&|)])" }, @@ -561,6 +670,7 @@ "id": "chmod-777", "kind": "command", "matcher": { + "case_sensitive": true, "kind": "regex", "pattern": "chmod\\s+(-R\\s+)?777" }, @@ -589,7 +699,9 @@ "mode": "enforce", "rules": [ { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "anthropic-api-key", "kind": "api-keys", @@ -603,7 +715,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "openai-project-key", "kind": "api-keys", @@ -617,7 +731,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "openai-api-key", "kind": "api-keys", @@ -630,7 +746,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-personal-token", "kind": "api-keys", @@ -644,7 +762,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-oauth-token", "kind": "api-keys", @@ -658,7 +778,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-server-token", "kind": "api-keys", @@ -672,7 +794,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-user-token", "kind": "api-keys", @@ -686,7 +810,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "github-fine-grained-token", "kind": "api-keys", @@ -700,7 +826,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "slack-bot-token", "kind": "api-keys", @@ -714,7 +842,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "slack-user-token", "kind": "api-keys", @@ -728,7 +858,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "slack-app-token", "kind": "api-keys", @@ -742,7 +874,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "aws-access-key-id", "kind": "api-keys", @@ -756,7 +890,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "aws-temporary-key-id", "kind": "api-keys", @@ -770,7 +906,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "google-api-key", "kind": "api-keys", @@ -784,7 +922,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "google-oauth-token", "kind": "api-keys", @@ -798,7 +938,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "gitlab-token", "kind": "api-keys", @@ -812,7 +954,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "stripe-live-key", "kind": "api-keys", @@ -826,7 +970,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "stripe-restricted-key", "kind": "api-keys", @@ -840,7 +986,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "npm-token", "kind": "api-keys", @@ -854,7 +1002,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "digitalocean-token", "kind": "api-keys", @@ -868,7 +1018,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "sendgrid-key", "kind": "api-keys", @@ -882,7 +1034,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "private-key", "kind": "private-keys", @@ -894,7 +1048,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "jwt", "kind": "jwt", @@ -906,7 +1062,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "conn-string-password", "kind": "conn-strings", @@ -918,7 +1076,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "cn-resident-id", "kind": "personal", @@ -931,7 +1091,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": true, "id": "bank-card", "kind": "personal", @@ -1057,7 +1219,9 @@ "on_by_default": true }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "email", "kind": "personal", @@ -1069,7 +1233,9 @@ "on_by_default": false }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "cn-mobile-phone", "kind": "personal", @@ -1081,7 +1247,9 @@ "on_by_default": false }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "internal-ip", "kind": "internal", @@ -1093,7 +1261,9 @@ "on_by_default": false }, { + "action": "placeholder", "custom": false, + "default_action": "placeholder", "enabled": false, "id": "internal-domain", "kind": "internal", @@ -1110,14 +1280,15 @@ "on_by_default": false }, { + "action": "placeholder", "custom": true, "enabled": true, "id": "客户编号", "kind": "custom", "label": "SECRET", "matcher": { - "kind": "regex", - "pattern": "CUST-\\d{6}" + "kind": "contains", + "text": "CUST-\\d{6}" }, "name": "客户编号", "on_by_default": true diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index ea6a4b92..babbb27b 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 46, + "api_version": 51, "clients": 4, "config_path": "", "gateway_addr": null, @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.70.0" + "version": "0.78.0" } diff --git a/src-tauri/src/clients/desktop_rule.rs b/src-tauri/src/clients/desktop_rule.rs index a42527d7..78ab608f 100644 --- a/src-tauri/src/clients/desktop_rule.rs +++ b/src-tauri/src/clients/desktop_rule.rs @@ -478,7 +478,7 @@ mod tests { "retention": {"body_days": 7, "row_days": 90, "body_max_bytes": 0, "body_bytes_now": 0}, "failover": {"failures_to_pause": 3, "pause_secs": 60, "max_pause_secs": 600, "no_balance_pause_secs": 1800, "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, "idle_timeout_secs": 300, - "slot_wait_secs": 30} + "wait_secs": 120} }) } diff --git a/src-tauri/src/import_link.rs b/src-tauri/src/import_link.rs index 9b80beb5..d17938a2 100644 --- a/src-tauri/src/import_link.rs +++ b/src-tauri/src/import_link.rs @@ -33,11 +33,11 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use serde::Serialize; -use tw_api::{BalanceSetting, Billing, Protocol, ZaiFamily}; +use tw_api::{BalanceSetting, Billing, LimitMeasure, LimitPer, Protocol, ZaiFamily}; use crate::wire::{ - ImportAuth, ImportAwsKeys, ImportHeader, ImportLink, ImportOAuth, ImportPrice, ImportProposal, - ImportService, ImportSheet, ImportSpec, ShareLink, + ImportAuth, ImportAwsKeys, ImportHeader, ImportLimit, ImportLink, ImportOAuth, ImportPrice, + ImportProposal, ImportService, ImportSheet, ImportSpec, ShareLink, }; /// 链接的前缀。主机名那一段不分大小写 @@ -57,6 +57,12 @@ pub const MAX_HEADERS: usize = 16; pub const MAX_HEADER_NAME: usize = 64; pub const MAX_HEADER_VALUE: usize = 1024; pub const MAX_CONCURRENCY: u32 = 1000; +/// 用量上限最多几条 +pub const MAX_LIMITS: usize = 16; +/// 请求数、token 数上限的上限(十五位数) +pub const MAX_LIMIT_COUNT: u64 = 999_999_999_999_999; +/// 费用上限最少多少,微分($0.01,core 的 `COST_MIN`) +pub const LIMIT_COST_MIN: u64 = 10_000; /// 规格里上下文窗口、输出上限的上限 pub const MAX_SPEC_TOKENS: u32 = 10_000_000; @@ -89,6 +95,7 @@ pub enum Param { Headers, ClientIdentity, Concurrency, + Limits, Balance, Models, ModelsOnly, @@ -118,6 +125,7 @@ impl Param { Self::Headers, Self::ClientIdentity, Self::Concurrency, + Self::Limits, Self::Balance, Self::Models, Self::ModelsOnly, @@ -148,6 +156,7 @@ impl Param { Self::Headers => "headers", Self::ClientIdentity => "client_identity", Self::Concurrency => "concurrency", + Self::Limits => "limits", Self::Balance => "balance", Self::Models => "models", Self::ModelsOnly => "models_only", @@ -846,6 +855,13 @@ fn build( Some(check_concurrency(c)?) } }; + let limits = match params.get(Param::Limits) { + None => Vec::new(), + Some(l) => { + offered(Param::Limits, !account)?; + check_limits(l)? + } + }; let balance = match params.get(Param::Balance) { None => None, Some(b) => { @@ -912,6 +928,7 @@ fn build( headers, client_identity, concurrency, + limits, balance, models, models_only, @@ -1215,6 +1232,102 @@ fn check_concurrency(c: &str) -> Result { whole(c, MAX_CONCURRENCY).ok_or(Rejected::Invalid(Param::Concurrency)) } +/// 「每 N」最多写几:分钟 60、小时 24。天、周、月没有 N(core 的 `every` 规则) +fn max_every(per: LimitPer) -> Option { + match per { + LimitPer::Minute => Some(60), + LimitPer::Hour => Some(24), + _ => None, + } +} + +/// 费用上限写成的美元数(`5`、`0.5`,至多六位小数)→ 微分 +fn dollars_to_micros(s: &str) -> Option { + let (i, f) = s.split_once('.').map_or((s, None), |(i, f)| (i, Some(f))); + let digits = |x: &str| !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit()); + if i.len() > 9 || !digits(i) || (i.len() > 1 && i.starts_with('0')) { + return None; + } + let frac: u64 = match f { + None => 0, + Some(f) if f.len() <= 6 && digits(f) => format!("{f:0<6}").parse().ok()?, + Some(_) => return None, + }; + Some(i.parse::().ok()? * 1_000_000 + frac) +} + +/// 微分写成美元:至多六位小数,末尾的 0 不写(`5`、`0.125`) +fn micros_text(m: u64) -> String { + let frac = format!("{:06}", m % 1_000_000); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + (m / 1_000_000).to_string() + } else { + format!("{}.{frac}", m / 1_000_000) + } +} + +/// 用量上限:`;` 分隔,每一项 `周期|计量|上限|N|缓存读取`。周期是 `minute` `hour` `day` `week` +/// `month`;计量是 `requests` `tokens` `cost`;上限是正整数,费用是美元(至少 0.01);N 是「每 N +/// 分钟 / 小时」(1–60 / 1–24),只给分钟、小时,不写是 1;缓存读取写 `y`,只给 token。末尾不写的 +/// 几格整个省掉。和 core 的配置校验同一套规矩:同一个周期(连同 N)、同一种量、缓存读取算法 +/// 相同的只能有一条 +fn check_limits(s: &str) -> Result, Rejected> { + let bad = Rejected::Invalid(Param::Limits); + let entries: Vec<&str> = s.split(';').collect(); + if entries.len() > MAX_LIMITS { + return Err(bad); + } + let mut out: Vec = Vec::new(); + for e in entries { + let f: Vec<&str> = e.split('|').collect(); + if !(3..=5).contains(&f.len()) { + return Err(bad); + } + let per = LimitPer::from_slug(f[0]).ok_or(bad)?; + let measure = LimitMeasure::from_slug(f[1]).ok_or(bad)?; + let max = match measure { + LimitMeasure::Cost => dollars_to_micros(f[2]).filter(|m| *m >= LIMIT_COST_MIN), + _ => { + let ok = !f[2].is_empty() + && f[2].len() <= 15 + && f[2].bytes().all(|b| b.is_ascii_digit()) + && !f[2].starts_with('0'); + ok.then(|| f[2].parse::().ok()) + .flatten() + .filter(|n| (1..=MAX_LIMIT_COUNT).contains(n)) + } + } + .ok_or(bad)?; + let every = match f.get(3).copied() { + None | Some("") => 1, + Some(n) => whole(n, max_every(per).ok_or(bad)?).ok_or(bad)?, + }; + let cache_reads = match f.get(4).copied() { + None | Some("") => false, + Some("y") if measure == LimitMeasure::Tokens => true, + Some(_) => return Err(bad), + }; + let limit = ImportLimit { + per, + every, + measure, + max, + cache_reads, + }; + if out.iter().any(|x| { + x.per == limit.per + && x.every == limit.every + && x.measure == limit.measure + && x.cache_reads == limit.cache_reads + }) { + return Err(bad); + } + out.push(limit); + } + Ok(out) +} + /// 模型规格:`;` 分隔,每一项 `模型|上下文窗口|输出上限|推理|图片输入` fn check_specs(s: &str) -> Result, Rejected> { let bad = Rejected::Invalid(Param::Specs); @@ -1399,6 +1512,30 @@ fn price_text(p: &ImportPrice) -> String { f.join("|") } +/// 一条用量上限:`周期|计量|上限|N|缓存读取`,N 是 1、缓存读取不算时那一格空着,末尾空着的 +/// 几格整个省掉 +fn limit_text(l: &ImportLimit) -> String { + let max = match l.measure { + LimitMeasure::Cost => micros_text(l.max), + _ => l.max.to_string(), + }; + let mut f = vec![l.per.slug().to_string(), l.measure.slug().to_string(), max]; + f.push(if l.every > 1 { + l.every.to_string() + } else { + String::new() + }); + f.push(if l.cache_reads { + "y".into() + } else { + String::new() + }); + while f.len() > 3 && f.last().is_some_and(String::is_empty) { + f.pop(); + } + f.join("|") +} + /// 一份提议写成链接的参数(`k=v&…`,值已编码),按 [`Param::ALL`] 的顺序 pub fn query(p: &ImportProposal) -> String { let mut out: Vec<(Param, String)> = vec![(Param::Service, p.service.slug().into())]; @@ -1465,6 +1602,10 @@ pub fn query(p: &ImportProposal) -> String { if let Some(c) = p.concurrency { put(Param::Concurrency, c.to_string()); } + if !p.limits.is_empty() { + let s: Vec = p.limits.iter().map(limit_text).collect(); + put(Param::Limits, s.join(";")); + } if let Some(b) = p.balance { put(Param::Balance, b.slug().into()); } diff --git a/src-tauri/src/import_link/tests.rs b/src-tauri/src/import_link/tests.rs index b71dfe44..11db108f 100644 --- a/src-tauri/src/import_link/tests.rs +++ b/src-tauri/src/import_link/tests.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; -use tw_api::{BalanceSetting, Billing, Protocol, ZaiFamily}; +use tw_api::{BalanceSetting, Billing, LimitMeasure, LimitPer, Protocol, ZaiFamily}; use super::*; @@ -84,6 +84,7 @@ fn blank(base_url: &str) -> ImportProposal { headers: vec![], client_identity: false, concurrency: None, + limits: vec![], balance: None, models: vec![], models_only: None, @@ -682,6 +683,7 @@ fn account_links_carry_no_credentials_or_connection_fields() { ("headers", "X-A: 1"), ("protocol", "openai-chat"), ("concurrency", "4"), + ("limits", "day|requests|100"), ("oauth_refresh", "r"), ] { let want = match k { @@ -690,6 +692,7 @@ fn account_links_carry_no_credentials_or_connection_fields() { "headers" => Param::Headers, "protocol" => Param::Protocol, "concurrency" => Param::Concurrency, + "limits" => Param::Limits, _ => Param::OauthRefresh, }; assert_eq!( @@ -1239,6 +1242,97 @@ fn concurrency_is_a_plain_whole_number_up_to_a_thousand() { } } +/// 用量上限:`周期|计量|上限|N|缓存读取`,和 core 的配置校验同一套规矩 +#[test] +fn limits_are_checked_like_the_config() { + let lim = |per, every, measure, max, cache_reads| ImportLimit { + per, + every, + measure, + max, + cache_reads, + }; + assert_eq!( + relay(&[( + "limits", + "minute|tokens|10000000|5;day|cost|5.5;week|tokens|900000||y;hour|requests|600|24;month|cost|0.01" + )]) + .unwrap() + .limits, + vec![ + lim(LimitPer::Minute, 5, LimitMeasure::Tokens, 10_000_000, false), + lim(LimitPer::Day, 1, LimitMeasure::Cost, 5_500_000, false), + lim(LimitPer::Week, 1, LimitMeasure::Tokens, 900_000, true), + lim(LimitPer::Hour, 24, LimitMeasure::Requests, 600, false), + lim(LimitPer::Month, 1, LimitMeasure::Cost, 10_000, false), + ] + ); + // N 不同就不是同一条;缓存读取算法不同也不是 + assert_eq!( + relay(&[( + "limits", + "minute|tokens|100|5;minute|tokens|100;minute|tokens|100||y" + )]) + .unwrap() + .limits + .len(), + 3 + ); + for bad in [ + "day|cost", + "day|cost|5|1|y|x", + "days|cost|5", + "day|usd|5", + "day|requests|0", + "day|requests|01", + "day|requests|1.5", + "day|requests|1000000000000000", + "day|cost|0", + "day|cost|0.009999", + "day|cost|5.1234567", + "day|cost|05", + "day|cost|.5", + "day|cost|5.", + "day|requests|5|2", + "day|requests|5|1", + "minute|requests|5|0", + "minute|requests|5|61", + "hour|requests|5|25", + "minute|requests|5|05", + "day|requests|5||y", + "day|cost|5||y", + "day|tokens|5||n", + "day|cost|5;day|cost|6", + "minute|tokens|5|3;minute|tokens|9|3", + "day|requests|5;", + ] { + assert_eq!( + relay(&[("limits", bad)]), + Err(Rejected::Invalid(Param::Limits)), + "{bad}" + ); + } + let many = vec!["day|requests|1"; MAX_LIMITS + 1].join(";"); + assert_eq!( + relay(&[("limits", &many)]), + Err(Rejected::Invalid(Param::Limits)) + ); + // 写回链接:N 是 1、缓存读取不算的那一格省掉,费用写美元 + let q = relay(&[( + "limits", + "minute|tokens|10000000|5;day|cost|5.50;week|tokens|900000||y;hour|requests|60|1", + )]) + .unwrap(); + assert!( + generate(&q).ends_with( + "&limits=minute%7Ctokens%7C10000000%7C5%3Bday%7Ccost%7C5.5%3Bweek%7Ctokens%7C900000%7C%7Cy%3Bhour%7Crequests%7C60" + ), + "{}", + generate(&q) + ); + round_trip(&q); +} + #[test] fn balance_is_only_for_custom() { let custom = |v: &str| relay_like("custom", &[("balance", v)]); @@ -1911,6 +2005,7 @@ fn every_proposal_the_parser_accepts_round_trips() { ("key", "sk-a+b/c=="), ("client_identity", "on"), ("concurrency", "8"), + ("limits", "minute|tokens|10000000|5;day|cost|20"), ("models", "claude-opus-5-5,claude-sonnet-5-5"), ("models_only", "claude-*,gpt-5?"), ], diff --git a/src-tauri/src/notices/fixtures/snapshot.json b/src-tauri/src/notices/fixtures/snapshot.json index d84d17e0..e85ff69f 100644 --- a/src-tauri/src/notices/fixtures/snapshot.json +++ b/src-tauri/src/notices/fixtures/snapshot.json @@ -72,6 +72,7 @@ "billing": "per-token", "references": [], "pricing": null, + "limits": [], "balance_setting": "auto", "balance": null, "signed_in": null @@ -179,7 +180,7 @@ "quota_pause_secs": 3600, "rate_limit_max_pause_secs": 3600, "idle_timeout_secs": 300, - "slot_wait_secs": 30 + "wait_secs": 120 }, "price_sheets": [] }, diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index d704e9cd..1400e5d9 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -690,6 +690,7 @@ async fn an_unreachable_upstream_is_only_listed_and_needs_real_evidence_to_clear usage: None, queued_ms: None, skipped: None, + pause_ms: None, proxy: None, }], billing: tw_api::Billing::PerToken, diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 8661ec16..613e9baa 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -911,6 +911,18 @@ pub struct ImportPrice { pub cache_write_1h: f64, } +/// 一条用量上限(上游配置里的 `limits:`)。和控制面的 `KeyLimitInput` 一样:费用是微分; +/// `every` 是「每 N 分钟 / 小时」的 N,天、周、月是 1 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +pub struct ImportLimit { + pub per: tw_api::LimitPer, + pub every: u32, + pub measure: tw_api::LimitMeasure, + pub max: u64, + /// 只有 token 上限会是 true + pub cache_reads: bool, +} + /// 链接带来的新价目表:和上游一起创建,名称跟着上游 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] pub struct ImportSheet { @@ -945,6 +957,8 @@ pub struct ImportProposal { pub client_identity: bool, /// 并发上限,1 到 1000 pub concurrency: Option, + /// 用量上限,按链接里的顺序 + pub limits: Vec, /// 余额从哪里读(只有「自定义」) pub balance: Option, /// 手动添加的模型 diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index 156105dc..16f43dd2 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -423,6 +423,7 @@ async fn a_streaming_request_can_be_followed_live_until_it_ends() { billing: None, pricing: None, max_concurrent: None, + limits: vec![], disabled: false, balance: None, }, @@ -655,6 +656,7 @@ async fn a_bedrock_upstream_is_saved_the_way_the_dialog_sends_it() { billing: None, pricing: None, max_concurrent: None, + limits: vec![], disabled: false, balance: None, }, diff --git a/src/events.test.ts b/src/events.test.ts index 6317034e..67dcc697 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -842,3 +842,113 @@ describe("库里读回来的本地应答", () => { expect(rows.get(1)).toMatchObject({ provider: "", local: true }); }); }); + +describe("排队中(request_queued)", () => { + const queued = (id: number, upstreams: string[] = ["anthropic", "openrouter"]) => + ({ kind: "request_queued", id, upstreams, until_ms: 1_120_000, at_ms: 1_000_500 }) satisfies CoreEvent; + const routed = (id: number) => + ({ + kind: "request_routed", + id, + route: "default", + rule: "catch-all", + group: "__all__", + rewritten_by: [], + attempts: [ + { provider: "anthropic", outcome: "error", ms: 0, skipped: "throttled" }, + { provider: "openrouter", outcome: "served", status: 200, ms: 900, queued_ms: 4_200 }, + ], + billing: "per-token", + }) satisfies CoreEvent; + + it("排队到路由出结论:在等的几家和最晚等到什么时候,路由之后撤掉", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, queued(1)); + expect(rows.get(1)?.queued).toEqual({ upstreams: ["anthropic", "openrouter"], untilMs: 1_120_000 }); + applyEvent(rows, routed(1)); + expect(rows.get(1)?.queued).toBeUndefined(); + expect(rows.get(1)?.provider).toBe("openrouter"); + }); + + it("发出去又收到 429、回去接着等:再来一条就又在排队", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, queued(1)); + applyEvent(rows, routed(1)); + applyEvent(rows, queued(1, ["openrouter"])); + expect(rows.get(1)?.queued?.upstreams).toEqual(["openrouter"]); + }); + + it("不排了(request_dequeued):等到了、正在发,路由事件还没到也不再说在排队", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, queued(1)); + const before = rows.get(1); + expect(applyBatch(rows, [{ kind: "request_dequeued", id: 1, at_ms: 1_004_000 }])).toBe(true); + expect(rows.get(1)).not.toBe(before); + expect(rows.get(1)?.queued).toBeUndefined(); + expect(rows.get(1)?.state).toBe("in_flight"); + // 半路才打开:快照里排过、又不排了的,照原样重放之后也不在排队 + const later = new Map(); + applyInFlight( + later, + [open(started({ id: 7 }), queued(7, ["anthropic"]), { kind: "request_dequeued", id: 7, at_ms: 1_004_000 })], + { started: new Set(), ended: new Set() }, + ); + expect(later.get(7)?.queued).toBeUndefined(); + }); + + it("排着队就结束了(等到头、客户端断开、手动中止):不再说在排队", () => { + const rows = new Map(); + for (const id of [1, 2, 3]) { + applyEvent(rows, started({ id })); + applyEvent(rows, queued(id)); + } + applyEvent(rows, { + kind: "request_failed", + id: 1, + model: "claude-sonnet-4-5", + source: "upstream", + message: { code: "gw.busy_all", text: "" }, + }); + applyEvent(rows, { kind: "request_cancelled", id: 2, model: "claude-sonnet-4-5", duration_ms: 5_000 }); + applyEvent(rows, { + kind: "request_finished", + id: 3, + model: "claude-sonnet-4-5", + status: 200, + duration_ms: 9_000, + sent_bytes: 1, + received_bytes: 1, + }); + for (const id of [1, 2, 3]) expect(rows.get(id)?.queued).toBeUndefined(); + }); + + it("半路才打开:快照里的排队照原样重放", () => { + const rows = new Map(); + applyInFlight(rows, [open(started({ id: 7 }), queued(7, ["anthropic"]))], { started: new Set(), ended: new Set() }); + expect(rows.get(7)?.queued?.upstreams).toEqual(["anthropic"]); + }); + + it("排队那一条换成新对象(表格据此重画);上游限流的事件不碰任何一行", () => { + const rows = new Map(); + applyEvent(rows, started()); + const before = rows.get(1); + expect(applyBatch(rows, [queued(1)])).toBe(true); + expect(rows.get(1)).not.toBe(before); + const now = rows.get(1); + expect( + applyBatch(rows, [ + { + kind: "throttle_changed", + id: 9, + provider: "anthropic", + throttle: { kind: "rate_limited", until_ms: 1_042_000 }, + at_ms: 1_000_000, + }, + ]), + ).toBe(false); + expect(rows.get(1)).toBe(now); + }); +}); diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index e270f0c9..f733db2b 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -1,6 +1,6 @@ // Generated from src-tauri/src/wire.rs (`tests/ts_bindings.rs`). Do not edit by hand. -import type { BalanceSetting, Billing, CostBucketGroup, CostGroup, HeadView, LiveBody, LiveEnd, Msg, PinnedModel, Protocol, ZaiFamily } from "./tw-api"; +import type { BalanceSetting, Billing, CostBucketGroup, CostGroup, HeadView, LimitMeasure, LimitPer, LiveBody, LiveEnd, Msg, PinnedModel, Protocol, ZaiFamily } from "./tw-api"; export type AdoptResponse = { real: string, backup: string, created: boolean, /** @@ -267,6 +267,16 @@ export type ImportAwsKeys = { access_key_id: string, secret_access_key: string, */ export type ImportHeader = { name: string, value: string, }; +/** + * 一条用量上限(上游配置里的 `limits:`)。和控制面的 `KeyLimitInput` 一样:费用是微分; + * `every` 是「每 N 分钟 / 小时」的 N,天、周、月是 1 + */ +export type ImportLimit = { per: LimitPer, every: number, measure: LimitMeasure, max: number, +/** + * 只有 token 上限会是 true + */ +cache_reads: boolean, }; + /** * 一条导入链接(`thinkwatch://import?…`)走到界面的结果(`take_import_link`):一份提议, * 或者链接里有这一版不认识的参数。写坏了的、值不合规矩的链接到不了这里(Rust 侧丢掉, @@ -320,6 +330,10 @@ client_identity: boolean, * 并发上限,1 到 1000 */ concurrency: number | null, +/** + * 用量上限,按链接里的顺序 + */ +limits: Array, /** * 余额从哪里读(只有「自定义」) */ diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 98e09466..148047f2 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 50; +export const CONTROL_API_VERSION = 51; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -288,15 +288,23 @@ error?: Msg | null, ms: number, */ usage?: AttemptUsage | null, /** - * 这一跳等了多少毫秒才轮到一个空位:这家设了 `max_concurrent` 而它满着。不算在 `ms` - * 里。没等的没有 + * 这一跳等了多少毫秒才轮到:这家满着(`max_concurrent`)、限流暂停着、分钟或小时的用量 + * 上限满了。不算在 `ms` 里。没等的没有 */ queued_ms?: number | null, /** - * 这一跳为什么没发出去:`busy`(这家满着,换了下一家;等过它的话 `queued_ms` 是等了 - * 多久)。这时 `outcome` 是 `error`,`error` 是同一件事的那句话。发出去了的没有 + * 这一跳为什么没发出去:`busy`(这家满着)、`throttled`(限流暂停着)、`window_full`(分钟、 + * 小时的用量上限满了),换了下一家或者等着它,等过它的话 `queued_ms` 是等了多久; + * `period_used_up`(天、周、月的用量上限用完了),跳过。这时 `outcome` 是 `error`,`error` + * 是同一件事的那句话。发出去了的没有 */ skipped?: ServeSkip | null, +/** + * 这一跳收到了 429(或者上游在回答的第一段内容之前报了限流),这家因此暂停了多少毫秒(见 + * [`ThrottleKind::RateLimited`])。请求接着换下一家或者等这家,客户端察觉不到。有它的一跳 + * 不算进这家的用量上限([`Self::counts_for_upstream`])。别的结果没有 + */ +pause_ms?: number | null, /** * 这一跳走的代理:这家上游配置的代理名(跟随系统的是 `system`),直连是 None。没发出去 * 的一跳(满着、被拒、转换不了……)是它要走的那个 —— 连不上的那一跳,问题可能就出在 @@ -1484,7 +1492,15 @@ usage?: UsageView | null, /** * 断开之前上游在回答里写的模型名(见 `RequestFinished::answered_model`) */ -answered_model?: string | null, } | { "kind": "request_routed", id: number, +answered_model?: string | null, } | { "kind": "request_queued", id: number, +/** + * 在等哪几家,按候选的顺序 + */ +upstreams: Array, +/** + * 最多等到什么时候(Unix 毫秒):这个请求的等待期限(`failover.wait_secs`) + */ +until_ms: number, at_ms: number, } | { "kind": "request_dequeued", id: number, at_ms: number, } | { "kind": "request_routed", id: number, /** * 走的哪条路由,同 `RequestStarted::route` */ @@ -1647,7 +1663,11 @@ detail: SecurityHitDetail, at_ms: number, } | { "kind": "request_priced", id: nu /** * `open` = 熔断中,不进候选链;`closed` = 可以用 */ -state: BreakerState, at_ms: number, } | { "kind": "models_changed", id: number, provider: string, at_ms: number, } | { "kind": "proxy_changed", id: number, proxy: string, +state: BreakerState, at_ms: number, } | { "kind": "throttle_changed", id: number, provider: string, +/** + * 现在的样子。又能发了是空 + */ +throttle?: Throttle | null, at_ms: number, } | { "kind": "models_changed", id: number, provider: string, at_ms: number, } | { "kind": "proxy_changed", id: number, proxy: string, /** * `unreachable` = 刚刚检查不通;`reachable` = 又通了 */ @@ -1839,10 +1859,10 @@ rate_limit_max_pause_secs: number, */ idle_timeout_secs: number, /** - * 一个请求合计最多等多少秒:等密钥的分钟、小时上限空出名额,和等满着(`max_concurrent`) - * 的上游空出位置,共用这一段。0 是不等 + * 一个请求合计最多等多少秒:等密钥的分钟、小时上限空出名额,和等暂时发不出去的上游(满着、 + * 限流暂停着、用量上限满了),共用这一段。0 是不等 */ -slot_wait_secs: number, }; +wait_secs: number, }; /** * 一个请求失败在哪一方。和 HTTP 响应里的 `x-thinkwatch-error` 同一个词表, @@ -2463,9 +2483,13 @@ allow?: Array | null, disabled?: boolean, limits?: Array, }; /** - * 新建、保存密钥时的一条用量上限。 + * 新建、保存密钥(和上游)时的一条用量上限。 */ -export type KeyLimitInput = { per: LimitPer, measure: LimitMeasure, +export type KeyLimitInput = { per: LimitPer, +/** + * 滚动窗口是几个 `per`:`minute` 1 到 60、`hour` 1 到 24。天、周、月交 1 + */ +every: number, measure: LimitMeasure, /** * 上限:请求数、token 数,费用是微分。要大于 0 */ @@ -2476,9 +2500,14 @@ max: number, cache_reads?: boolean, }; /** - * 一条用量上限,和它此刻用了多少。 + * 一条用量上限,和它此刻用了多少。密钥的([`ClientView::limits`])和上游的 + * ([`ProviderView::limits`])都是它。 + */ +export type KeyLimitView = { per: LimitPer, +/** + * 滚动窗口是几个 `per`:`hour` 的 5 是最近 5 小时。没写的是 1;天、周、月一律是 1 */ -export type KeyLimitView = { per: LimitPer, measure: LimitMeasure, +every: number, measure: LimitMeasure, /** * 上限:请求数、token 数,费用是微分 */ @@ -3745,6 +3774,10 @@ pricing?: string | null, * 同时最多发给这家几个请求,1 到 1000。不给就是不限 */ max_concurrent?: number | null, +/** + * 用量上限,整份替换。不给 = 一条都没有 + */ +limits: Array, /** * 停用 */ @@ -3946,6 +3979,11 @@ disabled: boolean, * `ok` / `open`(熔断中) */ health: Health, +/** + * 这家暂时发不出去:限流暂停着、用量上限满了或用完了(见 [`Throttle`])。能发是空。变化报 + * [`Event::ThrottleChanged`] + */ +throttle?: Throttle | null, /** * 上游拒绝了凭据:最近一次得到答复的请求回的是这个状态码(401 / 403)。 * 没被拒是空的,被拒之后有请求成功了也是空的。 @@ -3975,6 +4013,10 @@ pricing: string | null, * 同时最多发给这家几个请求。不限是空 */ max_concurrent?: number | null, +/** + * 用量上限,按配置里的顺序,各带此刻用了多少(发出去还没结束的按估算算)。没设的是空的 + */ +limits: Array, /** * 配置里的 `balance:`,原样:编辑对话框保存时交回去([`ProviderInput::balance`]) */ @@ -5205,7 +5247,7 @@ export type SecurityView = { redact: GuardMode, inspect_tools: GuardMode, conten /** * 一个上游为什么服务不了这个模型。 */ -export type ServeSkip = "disabled" | "out_of_scope" | "not_offered" | "not_allowed" | "busy"; +export type ServeSkip = "disabled" | "out_of_scope" | "not_offered" | "not_allowed" | "busy" | "throttled" | "window_full" | "period_used_up"; export type SessionDetail = { session: SessionView, turns: Array, }; @@ -5822,6 +5864,30 @@ skipped: number, */ notices: Array, }; +/** + * 一家上游暂时发不出去的状态([`ProviderView::throttle`]、[`Event::ThrottleChanged`])。 + * + * **不是失败**:上游没坏,不进熔断的账,也不算进成功率。几种同时成立时给 `until_ms` 最晚的 + * 那一种 —— 到那一刻之前它都发不出去。只在内存里:core 重启之后限流的暂停没有了,滚动窗口 + * 从空的开始,天、周、月照请求记录算回来。 + */ +export type Throttle = { kind: ThrottleKind, +/** + * 什么时候又能发(Unix 毫秒)。限流是暂停结束的时刻,`window_full` 是算出来的那一刻, + * `period_used_up` 是下一期开始的时刻 + */ +until_ms: number, +/** + * 是哪一条用量上限:[`ProviderView::limits`] 里的下标。`window_full` / `period_used_up` + * 才有,限流没有 + */ +limit?: number | null, }; + +/** + * 一家上游为什么暂时发不出去([`Throttle::kind`])。 + */ +export type ThrottleKind = "rate_limited" | "window_full" | "period_used_up"; + /** * 生成速度的中位数(`GET /token-rate`、`/token-rate/provider`),token/秒。 * diff --git a/src/i18n/core.zh.cases.json b/src/i18n/core.zh.cases.json index 0a7750e9..4150aaf1 100644 --- a/src/i18n/core.zh.cases.json +++ b/src/i18n/core.zh.cases.json @@ -364,5 +364,44 @@ "text": "Every upstream that can serve this request is at its concurrency limit (max_concurrent): `官方`, `中转`. None had a free slot in time; try again shortly." }, "zh": "可处理此请求的上游均已达到并发上限(max_concurrent):「官方」、「中转」。等待期间没有空出位置,请稍后重试。" + }, + { + "msg": { + "code": "gw.key_limit.tokens_every", + "args": { + "key": "laptop", + "max": "10000000", + "every": "5", + "per": "minute", + "used": "10002400", + "retry": "42" + }, + "text": "Gateway key `laptop` has reached its limit of 10000000 tokens every 5 minutes: 10002400 in the last 5 minutes. Try again in 42 s." + }, + "zh": "网关密钥「laptop」已达到每 5 分钟 10000000 个 token 的上限,最近 5 分钟内已使用 10002400 个 token,请于 42 秒后重试。" + }, + { + "msg": { + "code": "gw.upstream.paused", + "args": { + "upstream": "anthropic", + "secs": "30" + }, + "text": "Upstream `anthropic` answered 429 (rate limited) and is paused; it takes requests again in about 30 s." + }, + "zh": "上游「anthropic」返回 429(限流),已暂停发送,约 30 秒后恢复。" + }, + { + "msg": { + "code": "gw.wait.mixed", + "args": { + "rate_limited": "`anthropic`", + "window_full": "`openrouter`, `relay`", + "used_up": "—", + "busy": "—" + }, + "text": "No upstream that can serve this request became available in time. Paused after answering 429: `anthropic`. At a per-minute or per-hour usage limit: `openrouter`, `relay`. Used up for the current day, week or month: —. At the concurrency limit: —. Try again shortly." + }, + "zh": "等待期间没有可处理此请求的上游恢复可用。因 429 暂停:「anthropic」。达到按分钟或小时计的用量上限:「openrouter」、「relay」。当天、当周或当月的用量上限已用完:—。达到并发上限:—。请稍后重试。" } ] diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 32e6c61c..203ef6ef 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -377,6 +377,9 @@ "gw.key_limit.requests_rolling": "网关密钥「{key}」已达到每{per:limit_per} {max} 次请求的上限,最近一{per:limit_per}内已有 {used} 次,请于 {retry} 秒后重试。", "gw.key_limit.tokens_rolling": "网关密钥「{key}」已达到每{per:limit_per} {max} 个 token 的上限,最近一{per:limit_per}内已使用 {used} 个 token,请于 {retry} 秒后重试。", "gw.key_limit.cost_rolling": "网关密钥「{key}」已达到每{per:limit_per} {max} 的费用上限,最近一{per:limit_per}内已产生费用 {used},请于 {retry} 秒后重试。", + "gw.key_limit.requests_every": "网关密钥「{key}」已达到每 {every} {per:limit_per} {max} 次请求的上限,最近 {every} {per:limit_per}内已有 {used} 次,请于 {retry} 秒后重试。", + "gw.key_limit.tokens_every": "网关密钥「{key}」已达到每 {every} {per:limit_per} {max} 个 token 的上限,最近 {every} {per:limit_per}内已使用 {used} 个 token,请于 {retry} 秒后重试。", + "gw.key_limit.cost_every": "网关密钥「{key}」已达到每 {every} {per:limit_per} {max} 的费用上限,最近 {every} {per:limit_per}内已产生费用 {used},请于 {retry} 秒后重试。", "gw.config.no_upstreams": "尚未配置任何上游。请在 ThinkWatch Lite 中添加上游,或在 config.yaml 的 providers 中添加。", "gw.config.proxy_undefined": "上游「{upstream}」使用的代理「{proxy}」未在 proxies 中定义,内置选项只有 direct 和 system。", "gw.config.proxy_unusable": "上游「{upstream}」的代理「{proxy}」不可用:{detail}", @@ -470,6 +473,10 @@ "gw.route.no_upstream_alive": "没有可用的上游。", "gw.busy_upstream": "上游「{upstream}」已有 {limit} 个请求在进行,达到其并发上限(max_concurrent)。", "gw.busy_all": "可处理此请求的上游均已达到并发上限(max_concurrent):{upstreams!names}。等待期间没有空出位置,请稍后重试。", + "gw.wait.rate_limited": "可处理此请求的上游均因返回 429(限流)而暂停:{upstreams!names}。等待期间没有恢复,请稍后重试。", + "gw.wait.window_full": "可处理此请求的上游均已达到按分钟或小时计的用量上限:{upstreams!names}。等待期间没有空出额度,请稍后重试。", + "gw.wait.used_up": "可处理此请求的上游均已用完当天、当周或当月的用量上限:{upstreams!names}。在最早的重置时刻之前重试无效。", + "gw.wait.mixed": "等待期间没有可处理此请求的上游恢复可用。因 429 暂停:{rate_limited!names}。达到按分钟或小时计的用量上限:{window_full!names}。当天、当周或当月的用量上限已用完:{used_up!names}。达到并发上限:{busy!names}。请稍后重试。", "gw.route.upstream_missing": "配置中不存在「{upstream}」。", "gw.route.selected_upstream_missing": "规则「{rule}」选中的上游「{upstream}」在配置中不存在。", "gw.route.all_selected_disabled": "路由选中的上游均已停用:{detail}", @@ -491,6 +498,9 @@ "gw.upstream.forward_failed": "转发失败:{detail}", "gw.upstream.body_broken": "回答接收途中与上游的连接断开:{detail}", "gw.upstream.rate_limited": "上游「{upstream}」触发限流。", + "gw.upstream.paused": "上游「{upstream}」返回 429(限流),已暂停发送,约 {secs} 秒后恢复。", + "gw.upstream.window_full": "上游「{upstream}」已达到一条按分钟或小时计的用量上限,约 {secs} 秒后空出额度。", + "gw.upstream.period_used_up": "上游「{upstream}」当天、当周或当月的一条用量上限已用完,将于 {resets} 重置。", "gw.upstream.status": "上游「{upstream}」返回 {status}。", "gw.upstream.status_message": "上游「{upstream}」返回 {status}:{message}", "gw.upstream.stream_opening_error": "上游「{upstream}」开始回答后、给出任何内容之前报错({kind}):{message}", @@ -710,6 +720,17 @@ "config.key_limit_cache_reads": "网关密钥「{key}」的一条用量上限({measure:limit_measure})设置了 cache_reads,只有 token 数上限可以设置该项。", "config.key_limit_retention": "网关密钥「{key}」设置了每{per:limit_per}的用量上限,而 retention.row_days 为 {days}。重启后当{per:limit_per}用量要从请求记录中重新累计,因此 row_days 须至少为 {min}。", "config.key_limit_cost_too_small": "网关密钥「{key}」每{per:limit_per}的费用上限为 {value},须至少为 0.01。", + "config.key_limit_every": "网关密钥「{key}」一条每{per:limit_per}用量上限的 every 为 {every},per 为 {per} 时 every 须在 1 到 {max} 之间。", + "config.key_limit_every_calendar": "网关密钥「{key}」一条每{per:limit_per}的用量上限设置了 every,只有 per 为 minute 或 hour 的上限可以设置该项。天、周、月均从本地时间零点起算。", + "config.upstream_limit_empty": "上游「{upstream}」的一条用量上限没有写明计量。limits 中的每一条须写 requests、tokens、cost 之一。", + "config.upstream_limit_two_measures": "上游「{upstream}」的一条用量上限同时写了 {measures:limit_measures}。每一条只能写 requests、tokens、cost 之一,请分成多条。", + "config.upstream_limit_not_positive": "上游「{upstream}」的一条用量上限({measure:limit_measure},每{per:limit_per})为 {value},须大于 0。", + "config.upstream_limit_duplicate": "上游「{upstream}」有两条相同的用量上限({measure:limit_measure},每{per:limit_per}),请只保留一条。", + "config.upstream_limit_cache_reads": "上游「{upstream}」的一条用量上限({measure:limit_measure})设置了 cache_reads,只有 token 数上限可以设置该项。", + "config.upstream_limit_retention": "上游「{upstream}」设置了每{per:limit_per}的用量上限,而 retention.row_days 为 {days}。重启后当{per:limit_per}用量要从请求记录中重新累计,因此 row_days 须至少为 {min}。", + "config.upstream_limit_cost_too_small": "上游「{upstream}」每{per:limit_per}的费用上限为 {value},须至少为 0.01。", + "config.upstream_limit_every": "上游「{upstream}」一条每{per:limit_per}用量上限的 every 为 {every},per 为 {per} 时 every 须在 1 到 {max} 之间。", + "config.upstream_limit_every_calendar": "上游「{upstream}」一条每{per:limit_per}的用量上限设置了 every,只有 per 为 minute 或 hour 的上限可以设置该项。天、周、月均从本地时间零点起算。", "config.name_collision": "「{name}」同时是上游和策略组的名称,规则的 to 无法区分指的是哪一个。请重命名其中一个。", "config.bad_allow_from": "listen.gateway.allow_from 中的 {entry} 不是有效的 IP 地址或 CIDR,应写成 192.168.0.0/16 的形式。", "config.unknown_price_sheet": "上游「{upstream}」使用的价目表「{sheet}」不存在。", diff --git a/src/import/services.test.ts b/src/import/services.test.ts index 81391e72..ae8dcf86 100644 --- a/src/import/services.test.ts +++ b/src/import/services.test.ts @@ -57,6 +57,7 @@ function minimal(s: Service, auth: ImportAuth): ImportProposal { headers: [], client_identity: false, concurrency: null, + limits: [], balance: null, models: [], models_only: null, diff --git a/src/keys/LimitsEditor.i18n.ts b/src/keys/LimitsEditor.i18n.ts index a244fdb1..42f7ba97 100644 --- a/src/keys/LimitsEditor.i18n.ts +++ b/src/keys/LimitsEditor.i18n.ts @@ -8,12 +8,15 @@ export const limitsEditorText = messages( add: "添加上限", every: "每", atMost: "最多", + everyLabel: (n: number) => `第 ${n} 条上限的周期长度`, perLabel: (n: number) => `第 ${n} 条上限的周期`, maxLabel: (n: number) => `第 ${n} 条上限的数值`, measureLabel: (n: number) => `第 ${n} 条上限的计量`, cacheReads: "计入缓存读取", remove: "删除", removeLabel: (n: number) => `删除第 ${n} 条上限`, + /** 「每 N 分钟 / 小时」的 N 不在范围里 */ + badEvery: (hours: boolean, most: number) => `${hours ? "小时" : "分钟"}数须为 1 到 ${most} 之间的整数`, required: "请填写上限", notPositive: (cost: boolean): string => (cost ? "须为大于 0 的金额" : "须为大于 0 的整数"), costTooSmall: "金额须至少为 $0.01", @@ -32,12 +35,14 @@ export const limitsEditorText = messages( add: "Add limit", every: "Per", atMost: "at most", + everyLabel: (n: number) => `Period length of limit ${n}`, perLabel: (n: number) => `Period of limit ${n}`, maxLabel: (n: number) => `Amount of limit ${n}`, measureLabel: (n: number) => `Measure of limit ${n}`, cacheReads: "Count cache reads", remove: "Remove", removeLabel: (n: number) => `Remove limit ${n}`, + badEvery: (hours: boolean, most: number) => `${hours ? "Hours" : "Minutes"} must be a whole number from 1 to ${most}`, required: "Enter a limit", notPositive: (cost: boolean): string => (cost ? "Must be an amount above 0" : "Must be a whole number above 0"), costTooSmall: "Must be at least $0.01", diff --git a/src/keys/LimitsEditor.tsx b/src/keys/LimitsEditor.tsx index 9e35c49d..82b3465b 100644 --- a/src/keys/LimitsEditor.tsx +++ b/src/keys/LimitsEditor.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { PlusIcon } from "lucide-react"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; @@ -12,13 +12,18 @@ import { useNow } from "@/useNow"; import { Boxed, Note } from "@/upstreams/parts"; import type { KeyLimitView } from "@/types"; import { + MAX_EVERY, MEASURES, PERS, ROW_DAYS_NEEDED, amount, + cleanEvery, cleanMax, + everyOf, + isRolling, newRow, parseMax, + periodText, resetText, usageOf, type LimitProblem, @@ -32,10 +37,11 @@ const UNPRICED_SHOWN = 6; /** * 对话框里的「用量上限」:一条一行,读起来是一句话 ——「每 [天] 最多 [5] [费用 (USD)]」, - * token 上限再加一个「计入缓存读取」。 + * 分钟、小时前面多一格 N(「每 [5] [分钟] 最多 …」),token 上限再加一个「计入缓存读取」。 + * 密钥和上游的对话框共用它,说明那一句(`hint`)各自给。 * - * 编辑一把已有的密钥时,每一行下面写着此刻用了多少(core 的 `KeyLimitView`):天、周、月 - * 是这一期的,带重置的时刻;分钟、小时是最近这一段的。到了的那一行标出来。 + * 编辑一把已有的密钥、一个已有的上游时,每一行下面写着此刻用了多少(core 的 `KeyLimitView`): + * 天、周、月是这一期的,带重置的时刻;分钟、小时是最近这一段的。到了的那一行标出来。 * * 填错的当场说,写在那一行下面。**空着的那一格等离开它才说**:刚加的一行一出现就标红, * 说的是一件用户正要去做的事。删一条写成字,× 在这个应用里只表示关闭。 @@ -43,19 +49,25 @@ const UNPRICED_SHOWN = 6; export function LimitsEditor({ rows, views, - unpriced, + unpriced = [], problems, rowDays, + hint, + note, onChange, }: { rows: LimitRow[]; - /** core 给的这把密钥的上限和用量。新建时是空的 */ + /** core 给的上限和用量。新建时是空的 */ views: readonly KeyLimitView[]; - /** 这把密钥用得到、却没有价格的模型。core 只在已经存了费用上限时才算 */ - unpriced: readonly string[]; + /** 用得到、却没有价格的模型(只有密钥有)。core 只在已经存了费用上限时才算 */ + unpriced?: readonly string[]; problems: ReadonlyMap; /** 此刻请求记录留几天,「每月」那一条的提示用 */ rowDays: number | null; + /** 标题旁边那一句:用满之后会怎样。不给是密钥的说法 */ + hint?: string; + /** 列表下面的一条提醒(上游不计费时费用上限不起作用这类) */ + note?: ReactNode; onChange: (rows: LimitRow[]) => void; }) { const t = useText(limitsEditorText); @@ -82,7 +94,7 @@ export function LimitsEditor({
{t.title} - {t.hint} + {hint ?? t.hint}
{rows.length > 0 && ( @@ -116,6 +128,7 @@ export function LimitsEditor({ {rows.length === 0 && {t.none}}
{costLimited && unpriced.length > 0 && } + {note} ); } @@ -150,22 +163,41 @@ function LimitLine({ }) { const t = useText(limitsEditorText); const w = useText(limitsText); + const rolling = isRolling(r.per); + const every = everyOf(r.per, r.every); const said = - problem === "required" - ? t.required - : problem === "notPositive" - ? t.notPositive(r.measure === "cost") - : problem === "costTooSmall" - ? t.costTooSmall - : problem === "duplicate" - ? t.duplicate - : problem === "retention" - ? t.retention(w.per[r.per], ROW_DAYS_NEEDED[r.per] ?? 0, rowDays ?? 0) - : null; + problem === "every" + ? t.badEvery(r.per === "hour", MAX_EVERY[r.per] ?? 1) + : problem === "required" + ? t.required + : problem === "notPositive" + ? t.notPositive(r.measure === "cost") + : problem === "costTooSmall" + ? t.costTooSmall + : problem === "duplicate" + ? t.duplicate + : problem === "retention" + ? t.retention(w.per[r.per], ROW_DAYS_NEEDED[r.per] ?? 0, rowDays ?? 0) + : null; return (
{t.every} + {rolling && ( + onChange({ every: cleanEvery(e.target.value) })} + onKeyDown={(e) => { + if (e.key === "Enter") e.preventDefault(); + }} + /> + )} {PERS.map((p) => ( - {w.per[p]} + {w.unit(p, isRolling(p) ? (every ?? 1) : 1)} ))} @@ -246,7 +278,7 @@ function Usage({ row, use, now }: { row: LimitRow; use: KeyLimitView; now: numbe return (
- {w.period[row.per]}{" "} + {periodText(row.per, use.every)}{" "} {amount(row.measure, use.used)} / {amount(row.measure, max)} diff --git a/src/keys/limits.i18n.ts b/src/keys/limits.i18n.ts index 6f0ba177..f979e5cd 100644 --- a/src/keys/limits.i18n.ts +++ b/src/keys/limits.i18n.ts @@ -1,12 +1,21 @@ import { messages } from "@/i18n"; import type { LimitMeasure, LimitPer } from "@/types"; +const PER_ZH: Record = { minute: "分钟", hour: "小时", day: "天", week: "周", month: "月" }; +const PER_EN: Record = { minute: "minute", hour: "hour", day: "day", week: "week", month: "month" }; + /** * 用量上限的几样说法:对话框里的一行、密钥表里「已达上限」的悬停说明共用。 */ export const limitsText = messages( { - per: { minute: "分钟", hour: "小时", day: "天", week: "周", month: "月" } satisfies Record, + per: PER_ZH, + /** 周期的字,跟着「每 N」的 N 走(英文要分单复数) */ + unit: (per: LimitPer, _n: number): string => PER_ZH[per], + /** 「每」后面的那一段:「天」「分钟」「 5 分钟」(N 前后留空格,「每 5 分钟」) */ + span: (per: LimitPer, n: number): string => (n > 1 ? ` ${n} ${PER_ZH[per]}` : PER_ZH[per]), + /** 用量那一行开头,N 大于 1 的分钟、小时:「最近 5 分钟」 */ + lastN: (span: string) => `最近${span}`, measure: { requests: "次请求", tokens: "token", cost: "费用 (USD)" } satisfies Record, /** 用量那一行开头:天、周、月是这一期,分钟、小时是最近这一段 */ period: { @@ -22,7 +31,7 @@ export const limitsText = messages( on: (date: string, hm: string) => `${date} ${hm}`, locale: "zh-CN", reached: "已达上限", - /** 一条上限说成一句:「每天 $5.00 费用」「每分钟 30 次请求」 */ + /** 一条上限说成一句:「每天 $5.00 费用」「每分钟 30 次请求」。`per` 是 `span` 给的那一段 */ phrase: (per: string, amount: string, measure: LimitMeasure, cacheReads: boolean) => measure === "requests" ? `每${per} ${amount} 次请求` @@ -31,7 +40,10 @@ export const limitsText = messages( : `每${per} ${amount} 费用`, }, { - per: { minute: "minute", hour: "hour", day: "day", week: "week", month: "month" }, + per: PER_EN, + unit: (per: LimitPer, n: number): string => (n > 1 ? `${PER_EN[per]}s` : PER_EN[per]), + span: (per: LimitPer, n: number): string => (n > 1 ? `${n} ${PER_EN[per]}s` : PER_EN[per]), + lastN: (span: string) => `Last ${span}`, measure: { requests: "requests", tokens: "tokens", cost: "USD" }, period: { minute: "Last minute", diff --git a/src/keys/limits.test.ts b/src/keys/limits.test.ts index 51c25fcd..58edb672 100644 --- a/src/keys/limits.test.ts +++ b/src/keys/limits.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; import type { KeyLimitView } from "@/types"; import { + cleanEvery, cleanMax, + everyOf, inputOfView, inputsOf, limitPhrase, @@ -12,6 +14,7 @@ import { newRow, nextReset, parseMax, + periodText, resetText, rowsOf, usageOf, @@ -19,6 +22,7 @@ import { } from "./limits"; const view = (x: Partial & Pick): KeyLimitView => ({ + every: 1, cache_reads: false, used: 0, resets_at_ms: null, @@ -27,7 +31,7 @@ const view = (x: Partial & Pick>): LimitRow => - limitRow({ per: "day", measure: "cost", max: "5", cacheReads: false, ...x }); + limitRow({ per: "day", every: "1", measure: "cost", max: "5", cacheReads: false, ...x }); afterEach(() => setLang("zh")); @@ -70,9 +74,33 @@ describe("用量上限的一行", () => { it("缓存读取只跟着 token 上限走", () => { expect(inputsOf([row({ measure: "requests", max: "3", cacheReads: true })])).toEqual([ - { per: "day", measure: "requests", max: 3, cache_reads: false }, + { per: "day", every: 1, measure: "requests", max: 3, cache_reads: false }, ]); }); + + it("每 N 分钟 / 小时:N 来回不走样;天、周、月一直是 1", () => { + const views = [ + view({ per: "minute", every: 5, measure: "tokens", max: 10_000_000 }), + view({ per: "hour", every: 24, measure: "requests", max: 500 }), + view({ per: "day", measure: "cost", max: 5_000_000 }), + ]; + const rows = rowsOf(views); + expect(rows.map((r) => r.every)).toEqual(["5", "24", "1"]); + expect(inputsOf(rows)).toEqual(views.map(inputOfView)); + // 从分钟换成天,原来写的 N 不跟过去 + expect(inputsOf([row({ per: "day", every: "5", max: "3" })])[0]!.every).toBe(1); + }); + + it("N 的范围:分钟 1–60,小时 1–24", () => { + expect(everyOf("minute", "60")).toBe(60); + expect(everyOf("minute", "61")).toBeNull(); + expect(everyOf("hour", "24")).toBe(24); + expect(everyOf("hour", "25")).toBeNull(); + for (const bad of ["", "0", " "]) expect(everyOf("minute", bad), bad).toBeNull(); + expect(everyOf("week", "")).toBe(1); + expect(cleanEvery("5 分钟")).toBe("5"); + expect(cleanEvery("1234")).toBe("12"); + }); }); describe("当场校验", () => { @@ -114,6 +142,18 @@ describe("当场校验", () => { expect(limitProblems([row({ measure: "requests", max: "1" })], 90).size).toBe(0); }); + it("N 不对的那一行先说 N;N 不同就不算重复", () => { + const rows = [ + row({ per: "minute", every: "0", max: "" }), + row({ per: "hour", every: "25" }), + row({ per: "minute", every: "5", measure: "tokens", max: "100" }), + row({ per: "minute", every: "1", measure: "tokens", max: "100" }), + row({ per: "minute", every: "05", measure: "tokens", max: "100" }), + ]; + const p = limitProblems(rows, 90); + expect(rows.map((r) => p.get(r.id) ?? null)).toEqual(["every", "every", null, null, "duplicate"]); + }); + it("加一行先给还没有的那一种,不一加上就重复", () => { const first = newRow([]); expect([first.per, first.measure]).toEqual(["day", "cost"]); @@ -134,14 +174,34 @@ describe("用量", () => { expect(usageOf(row({ per: "week", measure: "tokens" }), views)).toBeUndefined(); }); + it("每 N 分钟的用量按 N 认", () => { + const views = [ + view({ per: "minute", every: 5, measure: "tokens", max: 100, used: 50 }), + view({ per: "minute", measure: "tokens", max: 100, used: 7 }), + ]; + expect(usageOf(row({ per: "minute", every: "5", measure: "tokens" }), views)?.used).toBe(50); + expect(usageOf(row({ per: "minute", measure: "tokens" }), views)?.used).toBe(7); + expect(usageOf(row({ per: "minute", every: "10", measure: "tokens" }), views)).toBeUndefined(); + expect(periodText("minute", 1)).toBe("最近一分钟"); + expect(periodText("minute", 5)).toBe("最近 5 分钟"); + expect(periodText("day", 1)).toBe("今天"); + setLang("en"); + expect(periodText("hour", 3)).toBe("Last 3 hours"); + expect(periodText("hour", 1)).toBe("Last hour"); + }); + it("一条上限说成一句", () => { expect(limitPhrase(view({ per: "day", measure: "cost", max: 5_000_000 }))).toBe("每天 $5.00 费用"); expect(limitPhrase(view({ per: "minute", measure: "requests", max: 30 }))).toBe("每分钟 30 次请求"); + expect(limitPhrase(view({ per: "minute", every: 5, measure: "tokens", max: 10_000_000 }))).toBe( + "每 5 分钟 10,000,000 token", + ); expect(limitPhrase(view({ per: "week", measure: "tokens", max: 1_000_000, cache_reads: true }))).toBe( "每周 1,000,000 token(含缓存读取)", ); setLang("en"); expect(limitPhrase(view({ per: "day", measure: "cost", max: 5_000_000 }))).toBe("$5.00 per day"); + expect(limitPhrase(view({ per: "hour", every: 2, measure: "requests", max: 100 }))).toBe("100 requests per 2 hours"); expect(limitPhrase(view({ per: "week", measure: "tokens", max: 1_000_000, cache_reads: true }))).toBe( "1,000,000 tokens per week (cache reads included)", ); diff --git a/src/keys/limits.ts b/src/keys/limits.ts index ad11fa7c..065502e4 100644 --- a/src/keys/limits.ts +++ b/src/keys/limits.ts @@ -1,10 +1,14 @@ /** - * 一把密钥的用量上限:对话框里一行一条,和 core 的 `KeyLimitView` / `KeyLimitInput` 来回换。 + * 用量上限:对话框里一行一条,和 core 的 `KeyLimitView` / `KeyLimitInput` 来回换。密钥和上游 + * 用的是同一套(上游的是 `ProviderView::limits` / `ProviderInput::limits`)。 * - * **写得对不对由 core 的配置校验说**(`config.key_limit_*`)。这里当场查的是同一套规则里 - * 填的时候就看得出来的几条,顺序也和 core 一样:要填、要大于 0、费用至少 $0.01、同一个周期 - * 同一种量(token 再分算不算缓存读取)只能有一条、天 / 周 / 月的上限要求请求记录至少留 - * 1 / 7 / 31 天 —— 免得填完整张对话框,保存时才被拒。 + * **写得对不对由 core 的配置校验说**(`config.key_limit_*`、`config.upstream_limit_*`)。这里 + * 当场查的是同一套规则里填的时候就看得出来的几条:分钟、小时的「每 N」在 1–60 / 1–24 之间、 + * 要填、要大于 0、费用至少 $0.01、同一个周期同一种量(token 再分算不算缓存读取)只能有一条、 + * 天 / 周 / 月的上限要求请求记录至少留 1 / 7 / 31 天 —— 免得填完整张对话框,保存时才被拒。 + * + * 分钟、小时是滚动的一段,可以是「每 5 分钟」(`every`);天、周、月是自然的一期,`every` + * 一直是 1。 * * 费用在 core 那边是微分(`max`、`used`),输入框里是美元。 */ @@ -16,6 +20,12 @@ import { limitsText } from "./limits.i18n"; export const PERS: readonly LimitPer[] = ["minute", "hour", "day", "week", "month"]; export const MEASURES: readonly LimitMeasure[] = ["requests", "tokens", "cost"]; +/** 滚动的两种周期,「每 N」最多写几(core 的 `every` 规则) */ +export const MAX_EVERY: Readonly>> = { minute: 60, hour: 24 }; + +/** 分钟、小时:滚动的一段,有「每 N」 */ +export const isRolling = (per: LimitPer): boolean => MAX_EVERY[per] != null; + /** * 天、周、月的上限要求请求记录至少留几天:重启之后这一期的用量从记录里加回来,留得比一期 * 短就加不全(core 的 `row_days_needed`)。分钟、小时的从空的开始,不要记录 @@ -30,6 +40,8 @@ export interface LimitRow { /** 只在对话框里用:行的 key,当场校验按它认行 */ id: number; per: LimitPer; + /** 「每 N 分钟 / 小时」的 N,输入框里的字。天、周、月不看它 */ + every: string; measure: LimitMeasure; /** 输入框里的字。费用是美元(`5`、`0.5`),别的是整数 */ max: string; @@ -44,13 +56,39 @@ export function limitRow(r: Omit): LimitRow { return { id: seq, ...r }; } +/** 一条上限的设定部分:core 的视图、输入,导入链接里的一条都有这几项 */ +export type LimitSetting = Pick; + /** 打开对话框时的那几行:照 core 给的,按配置里的顺序 */ -export function rowsOf(views: readonly KeyLimitView[] | undefined): LimitRow[] { +export function rowsOf(views: readonly LimitSetting[] | undefined): LimitRow[] { return (views ?? []).map((v) => - limitRow({ per: v.per, measure: v.measure, max: maxText(v.measure, v.max), cacheReads: v.cache_reads }), + limitRow({ + per: v.per, + every: String(isRolling(v.per) ? v.every : 1), + measure: v.measure, + max: maxText(v.measure, v.max), + cacheReads: v.cache_reads, + }), ); } +/** 「每 N」的输入框只留得下数字 */ +export function cleanEvery(raw: string): string { + return raw.replace(/[^0-9]/g, "").slice(0, 2); +} + +/** + * 这一行的「每 N」:分钟 1 到 60、小时 1 到 24 的整数;天、周、月一直是 1。写得不对是 null + */ +export function everyOf(per: LimitPer, text: string): number | null { + const most = MAX_EVERY[per]; + if (most == null) return 1; + const s = text.trim(); + if (!/^\d+$/.test(s)) return null; + const n = Number(s); + return n >= 1 && n <= most ? n : null; +} + /** 上限写进输入框的样子。费用是微分,写成美元、到分,再往下的照实写:5_500_000 → `5.50`,125_000 → `0.125` */ export function maxText(measure: LimitMeasure, max: number): string { if (measure !== "cost") return String(max); @@ -84,32 +122,35 @@ function cacheReadsOf(measure: LimitMeasure, cacheReads: boolean): boolean { return measure === "tokens" && cacheReads; } -/** core 认作同一条的:同一个周期、同一种量、缓存读取算法相同 */ -function identity(per: LimitPer, measure: LimitMeasure, cacheReads: boolean): string { - return `${per}:${measure}:${cacheReadsOf(measure, cacheReads)}`; +/** 认作同一条的:同一个周期(连同「每 N」)、同一种量、缓存读取算法相同 */ +function identity(per: LimitPer, every: number | null, measure: LimitMeasure, cacheReads: boolean): string { + return `${per}:${isRolling(per) ? every : 1}:${measure}:${cacheReadsOf(measure, cacheReads)}`; } +const rowIdentity = (r: LimitRow) => identity(r.per, everyOf(r.per, r.every), r.measure, r.cacheReads); + /** * 加一行时先给什么:**还没有的那一种**,免得一加上就和已有的重复。先按天算费用 —— * 管住一把密钥最常见的就是每天花多少;都有了就还是它,由当场校验说重复 */ export function newRow(rows: readonly LimitRow[]): LimitRow { - const taken = new Set(rows.map((r) => identity(r.per, r.measure, r.cacheReads))); + const taken = new Set(rows.map(rowIdentity)); const order: LimitPer[] = ["day", "month", "week", "hour", "minute"]; for (const measure of ["cost", "requests", "tokens"] as const) { for (const per of order) { - if (!taken.has(identity(per, measure, false))) return limitRow({ per, measure, max: "", cacheReads: false }); + if (!taken.has(identity(per, 1, measure, false))) + return limitRow({ per, every: "1", measure, max: "", cacheReads: false }); } } - return limitRow({ per: "day", measure: "cost", max: "", cacheReads: false }); + return limitRow({ per: "day", every: "1", measure: "cost", max: "", cacheReads: false }); } -export type LimitProblem = "required" | "notPositive" | "costTooSmall" | "duplicate" | "retention"; +export type LimitProblem = "every" | "required" | "notPositive" | "costTooSmall" | "duplicate" | "retention"; /** - * 每一行有什么不对,按行的 id。**一行只说一件**,和 core 查的先后一样:先说要填,再说要 - * 大于 0,再说费用不到 $0.01,再说重复(和前面哪一行一样,就标在后面那一行上),最后说 - * 天、周、月的上限要记录留够天数(`ROW_DAYS_NEEDED`)。 + * 每一行有什么不对,按行的 id。**一行只说一件**,按一行从左到右的顺序:先说「每 N」不在 + * 范围里,再说要填,再说要大于 0,再说费用不到 $0.01,再说重复(和前面哪一行一样,就标在 + * 后面那一行上),最后说天、周、月的上限要记录留够天数(`ROW_DAYS_NEEDED`)。 * * `rowDays`:此刻请求记录留几天(概览里的 `retention.row_days`)。不知道就不查这一条, * 保存时由 core 说 @@ -118,10 +159,11 @@ export function limitProblems(rows: readonly LimitRow[], rowDays: number | null) const out = new Map(); const seen = new Set(); for (const r of rows) { - const id = identity(r.per, r.measure, r.cacheReads); + const id = rowIdentity(r); const max = parseMax(r.measure, r.max); const need = ROW_DAYS_NEEDED[r.per]; - if (r.max.trim() === "") out.set(r.id, "required"); + if (everyOf(r.per, r.every) == null) out.set(r.id, "every"); + else if (r.max.trim() === "") out.set(r.id, "required"); else if (max == null) out.set(r.id, "notPositive"); else if (r.measure === "cost" && max < COST_MIN_MICROS) out.set(r.id, "costTooSmall"); else if (seen.has(id)) out.set(r.id, "duplicate"); @@ -135,22 +177,25 @@ export function limitProblems(rows: readonly LimitRow[], rowDays: number | null) export function inputsOf(rows: readonly LimitRow[]): KeyLimitInput[] { return rows.flatMap((r) => { const max = parseMax(r.measure, r.max); - return max == null ? [] : [{ per: r.per, measure: r.measure, max, cache_reads: cacheReadsOf(r.measure, r.cacheReads) }]; + const every = everyOf(r.per, r.every); + return max == null || every == null + ? [] + : [{ per: r.per, every, measure: r.measure, max, cache_reads: cacheReadsOf(r.measure, r.cacheReads) }]; }); } /** core 给的一条原样写回去(停用、启用这类只改别的字段的保存) */ -export function inputOfView(v: KeyLimitView): KeyLimitInput { - return { per: v.per, measure: v.measure, max: v.max, cache_reads: v.cache_reads }; +export function inputOfView(v: LimitSetting): KeyLimitInput { + return { per: v.per, every: v.every, measure: v.measure, max: v.max, cache_reads: v.cache_reads }; } /** - * 这一行此刻用了多少:core 给的那几条里,周期、量、缓存读取算法都和这一行一样的那条。 - * 新加的、改成了另一种的没有 —— 保存之前 core 没数过它 + * 这一行此刻用了多少:core 给的那几条里,周期(连同「每 N」)、量、缓存读取算法都和这一行 + * 一样的那条。新加的、改成了另一种的没有 —— 保存之前 core 没数过它 */ export function usageOf(row: LimitRow, views: readonly KeyLimitView[]): KeyLimitView | undefined { - const id = identity(row.per, row.measure, row.cacheReads); - return views.find((v) => identity(v.per, v.measure, v.cache_reads) === id); + const id = rowIdentity(row); + return views.find((v) => identity(v.per, v.every, v.measure, v.cache_reads) === id); } /** 一个用量或上限写成字:费用写美元,token 收成 k / M,请求数带千分位 */ @@ -160,11 +205,17 @@ export function amount(measure: LimitMeasure, n: number): string { return n.toLocaleString("en-US"); } -/** 一条上限说成一句:「每天 $5.00 费用」「1,000,000 tokens per day」。数字写全 */ -export function limitPhrase(v: Pick): string { +/** 一条上限说成一句:「每天 $5.00 费用」「每 5 分钟 10,000,000 token」「1,000,000 tokens per day」。数字写全 */ +export function limitPhrase(v: LimitSetting): string { const t = textOf(limitsText); const n = v.measure === "cost" ? usd(v.max) : v.max.toLocaleString("en-US"); - return t.phrase(t.per[v.per], n, v.measure, v.cache_reads); + return t.phrase(t.span(v.per, isRolling(v.per) ? v.every : 1), n, v.measure, v.cache_reads); +} + +/** 用量那一行开头说的是哪一段:「今天」「最近一分钟」「最近 5 分钟」 */ +export function periodText(per: LimitPer, every: number): string { + const t = textOf(limitsText); + return isRolling(per) && every > 1 ? t.lastN(t.span(per, every)) : t.period[per]; } const DAY_MS = 24 * 3_600_000; diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index 120a9096..71781d8f 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -84,12 +84,15 @@ export const labelsText = messages( /** 选定上游之后被规则拒绝的那一跳:没有发给这个上游 */ deniedHop: (rule: string) => `未发送 · 被规则「${rule}」拒绝`, // 没有上游接下的请求,在「上游」的位置上写的那一句:规则拒绝、没有可用的上游、 - // 密钥的用量上限拒绝、上游都满着 + // 密钥的用量上限拒绝、上游都满着、上游都在限流暂停、上游都到了用量上限、几种都有 notSent: { denied: "规则拒绝", unavailable: "无可用上游", limited: "用量上限", busy: "并发已满", + throttled: "上游限流中", + full: "上游用量已满", + timeout: "等待超时", }, // ------------------------------------------------------------ 请求与费用 @@ -198,6 +201,9 @@ export const labelsText = messages( unavailable: "No upstream", limited: "Usage limit", busy: "At capacity", + throttled: "Rate limited", + full: "Upstream limit", + timeout: "Wait timed out", }, quote: { diff --git a/src/labels.test.ts b/src/labels.test.ts index af10ba0d..7116bf8c 100644 --- a/src/labels.test.ts +++ b/src/labels.test.ts @@ -123,6 +123,30 @@ describe("尝试链里的一跳", () => { expect(attemptText(busy).text).toBe("At its concurrency limit"); }); + it("限流中、用量已满跳过的一跳:短名说原因", () => { + const skip = (skipped: "throttled" | "window_full" | "period_used_up") => ({ + provider: "anthropic", + outcome: "error" as const, + ms: 0, + skipped, + }); + expect(attemptText(skip("throttled")).text).toBe("限流中"); + expect(attemptText(skip("window_full")).text).toBe("用量已满"); + expect(attemptText(skip("period_used_up")).text).toBe("本期用量已满"); + setLang("en"); + expect(attemptText(skip("throttled")).text).toBe("Rate limited"); + expect(attemptText(skip("window_full")).text).toBe("Usage limit reached"); + expect(attemptText(skip("period_used_up")).text).toBe("Usage limit reached for this period"); + }); + + it("收到 429、暂停了这家的一跳写暂停多久;没暂停的照旧", () => { + const hop = { provider: "anthropic", outcome: "status" as const, status: 429, ms: 310 }; + expect(attemptText({ ...hop, pause_ms: 30_000 })).toEqual({ text: "429 · 暂停 30 秒", ok: false, tip: null }); + expect(attemptText(hop).text).toBe("429 · 限流"); + // 接下了的那一跳(流里中途限流)照旧写成功 + expect(attemptText({ ...hop, outcome: "served", status: 200, pause_ms: 5_000 }).text).toBe("成功 · 200"); + }); + it("别的结果照旧:字就是那一句,没有悬停", () => { expect(attemptText({ provider: "openrouter", outcome: "served", status: 200, ms: 900 })).toEqual({ text: "成功 · 200", diff --git a/src/labels.ts b/src/labels.ts index a5214291..64d0294a 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -23,6 +23,7 @@ import { type TranslatedView, } from "./types"; import { PROTOCOLS, skipLabel } from "./upstreams/labels"; +import { pausedText } from "./upstreams/throttle"; import { labelsText } from "./labels.i18n"; import { timeValueText } from "./routing/timeWindow"; import type { NotSent } from "./requestRouting"; @@ -183,6 +184,9 @@ export function attemptText(a: AttemptView): { text: string; ok: boolean; idle?: // 没有发出去的一跳:这家满着,换了下一家。短名说原因,悬停是 core 那一句(几个请求在 // 进行,也就是它的上限) if (a.skipped) return { text: skipLabel(a.skipped), ok: false, tip: said }; + // 收到 429、这家因此暂停了一阵的一跳:「429 · 暂停 30 秒」。请求随后换了别家或排队等它 + const paused = a.outcome !== "served" ? pausedText(a.status, a.pause_ms) : null; + if (paused) return { text: paused, ok: false, tip: said }; switch (a.outcome) { case "served": if (a.status == null || a.status < 400) { diff --git a/src/requestRouting.test.ts b/src/requestRouting.test.ts index 5da245b9..f47b1d3e 100644 --- a/src/requestRouting.test.ts +++ b/src/requestRouting.test.ts @@ -72,6 +72,14 @@ describe("没有发往任何上游的请求", () => { expect(notSent({ provider: "", error: busyAll })).toBe("busy"); }); + it("上游都在限流暂停、都到了用量上限、几种都有:同满着一样,不算最后那一家的失败", () => { + const wait = (code: string): Msg => ({ code, args: { upstreams: "`anthropic`, `openrouter`" }, text: "" }); + expect(notSent({ provider: "openrouter", error: wait("gw.wait.rate_limited") })).toBe("throttled"); + expect(notSent({ provider: "openrouter", error: wait("gw.wait.window_full") })).toBe("full"); + expect(notSent({ provider: "", error: wait("gw.wait.used_up") })).toBe("full"); + expect(notSent({ provider: "openrouter", error: wait("gw.wait.mixed") })).toBe("timeout"); + }); + it("发往了上游的、本地应答的、还没有结局的都不算", () => { // 选定上游之后才被拒绝:记在要去的那个上游上 expect(notSent({ provider: "openrouter", error: denied("no-images", "no images") })).toBeNull(); @@ -131,6 +139,23 @@ describe("路由那一页", () => { expect(g.note).toEqual({ kind: "switched", count: 2 }); }); + it("收到 429 暂停、限流中跳过的不说成失败:换过上游,说前几次尝试没有接下", () => { + const paused: AttemptView = { provider: "anthropic", outcome: "status", status: 429, ms: 310, pause_ms: 30_000 }; + const throttled: AttemptView = { provider: "openrouter", outcome: "error", ms: 0, skipped: "throttled" }; + const f = routingFacts(row({ attempts: [paused, throttled, served("deepseek")] }, { provider: "deepseek" }), false)!; + expect(f.note).toEqual({ kind: "switched", count: 2 }); + }); + + it("等前面试过的那一家恢复、发给了它:没有换上游", () => { + const paused: AttemptView = { provider: "anthropic", outcome: "status", status: 429, ms: 310, pause_ms: 30_000 }; + const full: AttemptView = { provider: "openrouter", outcome: "error", ms: 0, skipped: "window_full" }; + const f = routingFacts( + row({ attempts: [paused, full, { ...served("anthropic"), queued_ms: 30_090 }] }), + false, + )!; + expect(f.note).toEqual({ kind: "waited", count: 2 }); + }); + it("等到了空位:只有一跳,排队的时间在那一跳上,不加说明", () => { const f = routingFacts(row({ attempts: [{ ...served("anthropic"), queued_ms: 1_240 }] }), false)!; expect(f.note).toBeNull(); @@ -155,6 +180,15 @@ describe("路由那一页", () => { expect(f.note).toEqual({ kind: "busy", tried: 1 }); }); + it("上游都在限流暂停:收到 429 的那一跳算发出去过,跳过的不算", () => { + const why: Msg = { code: "gw.wait.rate_limited", args: { upstreams: "`anthropic`, `openrouter`" }, text: "" }; + const paused: AttemptView = { provider: "anthropic", outcome: "status", status: 429, ms: 310, pause_ms: 30_000 }; + const throttled: AttemptView = { provider: "openrouter", outcome: "error", ms: 0, skipped: "throttled" }; + const f = routingFacts(row({ attempts: [paused, throttled] }, { provider: "openrouter", error: why }), false)!; + expect(f.note).toEqual({ kind: "throttled", tried: 1 }); + expect(f.reason).toEqual({ msg: why }); + }); + it("密钥的用量上限拒绝了它:没有尝试,原因是 core 说的那一句", () => { const f = routingFacts(row({ attempts: [] }, { provider: "", error: limited }), false)!; expect(f.ruleDenied).toBe(false); diff --git a/src/requestRouting.ts b/src/requestRouting.ts index d21347a4..3b4b4e7b 100644 --- a/src/requestRouting.ts +++ b/src/requestRouting.ts @@ -11,6 +11,18 @@ const DENIED = "gw.route.denied"; const KEY_LIMIT = "gw.key_limit."; /** 能服务的上游都满着、等过了也没空出位置:`gw.busy_all {upstreams}` */ const BUSY_ALL = "gw.busy_all"; +/** + * 能服务的上游都暂时发不出去、等到期限也没有一家恢复:都在限流暂停(`gw.wait.rate_limited`)、 + * 都到了分钟或小时的用量上限(`gw.wait.window_full`)、都用完了天周月的用量上限 + * (`gw.wait.used_up`,不等)、几种都有(`gw.wait.mixed`)。和 `gw.busy_all` 一样,上游那一格 + * 是最后看过的那一家,可它没接下这个请求 + */ +const WAIT: Record = { + "gw.wait.rate_limited": "throttled", + "gw.wait.window_full": "full", + "gw.wait.used_up": "full", + "gw.wait.mixed": "timeout", +}; /** * 一条请求为什么没有上游接下: @@ -19,6 +31,9 @@ const BUSY_ALL = "gw.busy_all"; * · `unavailable`:规则选中的上游一个都接不了(停用、不在范围内、不提供这个模型) * · `limited`:这把网关密钥的用量上限拒绝了它(选定上游之后、发出之前) * · `busy`:能服务它的上游都满着(各自的并发上限),等过了也没空出位置 + * · `throttled`:能服务它的上游都因 429 暂停着,等过了也没恢复 + * · `full`:能服务它的上游都到了自己的用量上限(分钟、小时的等过了也没空出来;天、周、月的不等) + * · `timeout`:上面几种都有,等到期限也没有一家恢复 * * 发往了上游的、本地应答的、还没有结局的是 `null`。 * @@ -29,11 +44,16 @@ const BUSY_ALL = "gw.busy_all"; * **`busy` 的上游不是空的**:那一行归在尝试链的最后一跳,也就是最后看过、满着的那一家。 * 可那一家一个字节都没收到,结局也不是它的失败 —— 这一格写「并发已满」,不写它的名字 */ -export type NotSent = "denied" | "unavailable" | "limited" | "busy"; +export type NotSent = "denied" | "unavailable" | "limited" | "busy" | "throttled" | "full" | "timeout"; + +/** 等过了、没有一家上游接下的那几种:上游那一格不是空的,也不写它的名字 */ +const WAITED_OUT: ReadonlySet = new Set(["busy", "throttled", "full", "timeout"]); export function notSent(r: { local?: boolean; provider: string; error?: Msg | null }): NotSent | null { if (r.local || !r.error) return null; if (r.error.code === BUSY_ALL) return "busy"; + const waited = WAIT[r.error.code]; + if (waited) return waited; if (r.provider !== "") return null; if (r.error.code === DENIED) return "denied"; return r.error.code.startsWith(KEY_LIMIT) ? "limited" : "unavailable"; @@ -47,6 +67,14 @@ export function skippedHop(a: AttemptView): boolean { return a.skipped != null; } +/** + * 不算上游失败的一跳:没发出去就换了下一家(满着、限流中、用量已满),或者收到 429、这家 + * 因此暂停了一阵(`pause_ms`)—— 等它恢复就能接着发,不是故障 + */ +function notFailed(a: AttemptView): boolean { + return skippedHop(a) || a.pause_ms != null; +} + /** 尝试链里的一跳。`denied`:选定上游之后的规则在这一跳拒绝了它,**没有发给这个上游** */ export interface Hop { attempt: AttemptView; @@ -57,11 +85,15 @@ export interface Hop { * 尝试链下面那一句。只说字面上成立的事: * * · `failover`:前 `failed` 个上游失败,换到了下一个(最后一跳的结果在它自己那一行) - * · `switched`:前 `count` 跳没有接下它,换到了下一跳。其中有满着跳过的 —— 那不是上游的 - * 失败,不说「失败」(每一跳为什么没接下在它自己那一行) + * · `switched`:前 `count` 跳没有接下它,换到了下一跳。其中有满着、限流中跳过的,或者收到 + * 429 暂停了的 —— 那不是上游的失败,不说「失败」(每一跳为什么没接下在它自己那一行) + * · `waited`:前 `count` 跳没有接下它,最后接下的是前面试过的一家:等它恢复(限流结束、 + * 用量窗口空出来、空出位置)之后发了过去,没有换上游 * · `limited`:这把网关密钥的用量上限拒绝了它:没有发往任何上游 * · `busy`:剩下的上游都满着,等过了也没空出位置。`tried` 是在那之前真的发出去、没成的 * 几跳;0 就是没有发往任何上游 + * · `throttled` / `full` / `timeout`:同 `busy`,剩下的上游都在限流暂停 / 都到了用量上限 / + * 几种都有(见 `NotSent`) * · `failover_denied`:前 `failed` 个上游失败,换到下一个之后被规则 `rule` 拒绝,没有发给它 * · `denied_after_pick`:唯一的那一跳被规则 `rule` 拒绝:没有发往任何上游 * · `denied_before_pick`:选定上游之前规则 `rule` 就拒绝了它:没有发往任何上游 @@ -74,8 +106,9 @@ export interface Hop { export type RoutingNote = | { kind: "failover"; failed: number } | { kind: "switched"; count: number } + | { kind: "waited"; count: number } | { kind: "limited" } - | { kind: "busy"; tried: number } + | { kind: "busy" | "throttled" | "full" | "timeout"; tried: number } | { kind: "failover_denied"; failed: number; rule: string } | { kind: "denied_after_pick"; rule: string } | { kind: "denied_before_pick"; rule: string } @@ -138,15 +171,15 @@ export function routingFacts( if (ruleDenied || deniedBy) { const text = denyReason(deniedHop?.error) ?? denyReason(r.error); reason = text ? { text } : null; - } else if ((why === "unavailable" || why === "limited" || why === "busy") && r.error) { + } else if ((why === "unavailable" || why === "limited" || (why && WAITED_OUT.has(why))) && r.error) { // 用量上限的那一句说清是哪一条、用了多少、什么时候重置;满着的那一句列出是哪几家 reason = { msg: r.error }; } let note: RoutingNote | null = null; - if (why === "busy") { - // 满着跳过的几跳都没发出去。之前真的发出去、没成的那几跳另算 - note = { kind: "busy", tried: attempts.filter((a) => !skippedHop(a)).length }; + if (why === "busy" || why === "throttled" || why === "full" || why === "timeout") { + // 满着、限流中、用量已满跳过的几跳都没发出去。之前真的发出去、没成的那几跳另算 + note = { kind: why, tried: attempts.filter((a) => !skippedHop(a)).length }; } else if (n === 0) { if (ruleDenied) note = { kind: "denied_before_pick", rule: routing.rule }; else if (why === "unavailable") note = { kind: "unavailable" }; @@ -159,9 +192,15 @@ export function routingFacts( ? { kind: "failover_denied", failed: n - 1, rule: deniedBy } : { kind: "denied_after_pick", rule: deniedBy }; } else if (n > 1) { - // 满着跳过不是上游的失败(无响应超时是) - const plain = attempts.slice(0, -1).every((a) => !skippedHop(a)); - note = plain ? { kind: "failover", failed: n - 1 } : { kind: "switched", count: n - 1 }; + // 满着、限流中跳过,收到 429 暂停,都不是上游的失败(无响应超时是) + const earlier = attempts.slice(0, -1); + const last = attempts[n - 1]!; + const plain = earlier.every((a) => !notFailed(a)); + note = earlier.some((a) => a.provider === last.provider) + ? { kind: "waited", count: n - 1 } + : plain + ? { kind: "failover", failed: n - 1 } + : { kind: "switched", count: n - 1 }; } return { diff --git a/src/requestTable.test.ts b/src/requestTable.test.ts index 2ab65ce3..fa59543c 100644 --- a/src/requestTable.test.ts +++ b/src/requestTable.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { EMPTY_FILTER, facets, filterRows, sortRows, upstreamText } from "./requestTable"; +import { EMPTY_FILTER, facets, filterRows, queuedFor, sortRows, upstreamText } from "./requestTable"; import type { RequestRow } from "./types"; import { setLang } from "@/i18n"; import { plain } from "@/i18n/core.i18n"; @@ -248,3 +248,17 @@ describe("只看无法计价的", () => { expect(filterRows(rows, EMPTY_FILTER)).toHaveLength(2); }); }); + +describe("排着队的那一行", () => { + const waiting = { upstreams: ["anthropic", "openrouter"], untilMs: 2_000_000 }; + + it("在跑、排着队:在等的那几家", () => { + expect(queuedFor(row({ id: 1, state: "in_flight", queued: waiting }))).toEqual(["anthropic", "openrouter"]); + }); + + it("没在排、已经结束、说不出等谁的:没有", () => { + expect(queuedFor(row({ id: 1, state: "in_flight" }))).toBeNull(); + expect(queuedFor(row({ id: 1, state: "failed", queued: waiting }))).toBeNull(); + expect(queuedFor(row({ id: 1, state: "in_flight", queued: { upstreams: [], untilMs: 0 } }))).toBeNull(); + }); +}); diff --git a/src/requestTable.ts b/src/requestTable.ts index 94aec379..2eadf5c1 100644 --- a/src/requestTable.ts +++ b/src/requestTable.ts @@ -88,6 +88,14 @@ export function upstreamText(r: RequestRow): string { return r.local ? textOf(requestsText).answeredLocally : r.provider; } +/** + * 排着队的那一行在等哪几家上游(`request_queued` 之后、路由出结论之前)。不在排队、 + * 已经结束的是 null。事件里说不出是哪几家(空的)也当没在排 + */ +export function queuedFor(r: Pick): string[] | null { + return r.state === "in_flight" && r.queued && r.queued.upstreams.length > 0 ? r.queued.upstreams : null; +} + function valueOf(r: RequestRow, key: SortKey): number | null { switch (key) { case "time": diff --git a/src/settings/FailoverSection.i18n.ts b/src/settings/FailoverSection.i18n.ts index e6478294..50176c79 100644 --- a/src/settings/FailoverSection.i18n.ts +++ b/src/settings/FailoverSection.i18n.ts @@ -12,7 +12,7 @@ export const failoverText = messages( quota_pause_secs: "额度用完", rate_limit_max_pause_secs: "限流", idle_timeout_secs: "无响应超时", - slot_wait_secs: "最多等待空位", + wait_secs: "最多等待", }, what: { failures_to_pause: "服务器错误、无法连接等未说明原因的失败,连续达到此次数后暂停。", @@ -23,7 +23,8 @@ export const failoverText = messages( rate_limit_max_pause_secs: "上游限流时按其要求的等待时间暂停,最长为此值。", idle_timeout_secs: "上游连续这么久没有发出内容即放弃:尚未向客户端发出内容时转到下一个上游,已经发出时以错误结束。默认 300 秒。", - slot_wait_secs: "上游并发已满或密钥的分钟、小时上限用满时,请求合计最多等待的时长。0 表示不等待。", + wait_secs: + "上游并发已满、被限流或用量上限用满,以及密钥的分钟、小时上限用满时,请求合计最多等待的时长,超过后返回错误。默认 120 秒,0 表示不等待。", }, times: "次", secs: "秒", @@ -31,7 +32,7 @@ export const failoverText = messages( badSecs: "须为 1 到 604800 之间的整数。", badMax: "须为整数,不小于暂停时长,不超过 604800。", badIdle: "须为 30 到 3600 之间的整数。", - badSlotWait: "须为 0 到 300 之间的整数。", + badWait: "须为 0 到 600 之间的整数。", saveFailed: "未能保存", }, { @@ -46,7 +47,7 @@ export const failoverText = messages( quota_pause_secs: "Quota used up", rate_limit_max_pause_secs: "Rate limit", idle_timeout_secs: "No-response timeout", - slot_wait_secs: "Wait for a free slot at most", + wait_secs: "Wait at most", }, what: { failures_to_pause: @@ -59,7 +60,8 @@ export const failoverText = messages( rate_limit_max_pause_secs: "A rate-limited upstream is paused for the wait it asks for, at most this long.", idle_timeout_secs: "An upstream that sends no content for this long is given up on: the request moves to the next upstream if nothing has reached the client yet, and ends with an error otherwise. 300 s by default.", - slot_wait_secs: "Total time a request waits for a full upstream or a key's per-minute or per-hour limit. 0 means no wait.", + wait_secs: + "Total time a request waits while upstreams are full, rate-limited or at a usage limit, or while a key's per-minute or per-hour limit is used up. After that it ends with an error. 120 s by default; 0 means no wait.", }, times: "times", secs: "s", @@ -67,7 +69,7 @@ export const failoverText = messages( badSecs: "A whole number from 1 to 604800.", badMax: "A whole number, not less than the pause and at most 604800.", badIdle: "A whole number from 30 to 3600.", - badSlotWait: "A whole number from 0 to 300.", + badWait: "A whole number from 0 to 600.", saveFailed: "Not saved", }, ); diff --git a/src/settings/FailoverSection.tsx b/src/settings/FailoverSection.tsx index 898c0475..1f97a9c9 100644 --- a/src/settings/FailoverSection.tsx +++ b/src/settings/FailoverSection.tsx @@ -13,8 +13,8 @@ const MAX_PAUSE = 7 * 24 * 3600; /** 无响应超时最少、最多写多少秒(core 的 `MIN_IDLE_TIMEOUT_SECS`、`MAX_IDLE_TIMEOUT_SECS`) */ const MIN_IDLE = 30; const MAX_IDLE = 3600; -/** 等空位最多写多少秒(core 的 `MAX_SLOT_WAIT_SECS`) */ -const MAX_SLOT_WAIT = 300; +/** 最多等待写多少秒(core 的 `MAX_WAIT_SECS`) */ +const MAX_WAIT = 600; /** 这一节里的格子,全是数字 */ type Field = keyof FailoverView; @@ -29,7 +29,7 @@ const FIELDS: Field[] = [ "quota_pause_secs", "rate_limit_max_pause_secs", "idle_timeout_secs", - "slot_wait_secs", + "wait_secs", ]; /** 导出给测试用 */ @@ -58,15 +58,16 @@ export function checks(draft: Draft, saved: Draft): Record { rate_limit_max_pause_secs: secs("rate_limit_max_pause_secs"), idle_timeout_secs: draft.idle_timeout_secs === saved.idle_timeout_secs || intIn(draft.idle_timeout_secs, MIN_IDLE, MAX_IDLE), - slot_wait_secs: draft.slot_wait_secs === saved.slot_wait_secs || intIn(draft.slot_wait_secs, 0, MAX_SLOT_WAIT), + wait_secs: draft.wait_secs === saved.wait_secs || intIn(draft.wait_secs, 0, MAX_WAIT), }; } /** - * 上游失败之后停用多久、多久没有内容就不再等它(无响应超时)、上游满着时最多等多久。 + * 上游失败之后停用多久、多久没有内容就不再等它(无响应超时)、上游暂时发不出去(满着、限流、 + * 用量用满)时请求最多等多久。 * - * 默认值显式写在格子里(概览给的就是真在用的数),不用「留空 = 默认」;无响应超时的说明 - * 里另写默认的 300 秒。 + * 默认值显式写在格子里(概览给的就是真在用的数),不用「留空 = 默认」;无响应超时、最多等待 + * 的说明里另写默认的 300 秒、120 秒。 */ export function FailoverSection({ failover, @@ -150,7 +151,7 @@ export function FailoverSection({ {row("quota_pause_secs", t.secs, t.what.quota_pause_secs, t.badSecs)} {row("rate_limit_max_pause_secs", t.secs, t.what.rate_limit_max_pause_secs, t.badSecs)} {row("idle_timeout_secs", t.secs, t.what.idle_timeout_secs, t.badIdle)} - {row("slot_wait_secs", t.secs, t.what.slot_wait_secs, t.badSlotWait)} + {row("wait_secs", t.secs, t.what.wait_secs, t.badWait)} ) => Object.values(c).every(Boolean); @@ -37,13 +37,11 @@ describe("故障转移", () => { expect(checks({ ...saved, pause_secs: "900", max_pause_secs: "900" }, saved).max_pause_secs).toBe(true); }); - it("等空位的秒数可以是 0(不等),最多 300", () => { + it("最多等待:默认 120 秒,可以是 0(不等),最多 600", () => { const saved = draftOf(failover); - expect(saved.slot_wait_secs).toBe("30"); - expect(checks({ ...saved, slot_wait_secs: "0" }, saved).slot_wait_secs).toBe(true); - expect(checks({ ...saved, slot_wait_secs: "300" }, saved).slot_wait_secs).toBe(true); - expect(checks({ ...saved, slot_wait_secs: "301" }, saved).slot_wait_secs).toBe(false); - expect(checks({ ...saved, slot_wait_secs: "" }, saved).slot_wait_secs).toBe(false); + expect(saved.wait_secs).toBe("120"); + for (const ok of ["0", "1", "300", "600"]) expect(checks({ ...saved, wait_secs: ok }, saved).wait_secs).toBe(true); + for (const bad of ["601", "", "-1", "12.5"]) expect(checks({ ...saved, wait_secs: bad }, saved).wait_secs).toBe(false); }); it("无响应超时:默认 300 秒,30 到 3600 之间的整数", () => { diff --git a/src/traffic/RequestTable.tsx b/src/traffic/RequestTable.tsx index 039d95ac..9e52d6a1 100644 --- a/src/traffic/RequestTable.tsx +++ b/src/traffic/RequestTable.tsx @@ -19,7 +19,8 @@ import { compact, latency, money, ms, statusTone, tokens, traffic, when } from " import { failureKind, notSentText, translatedText } from "@/labels"; import { ruleName } from "@/security/labels"; import { notSent } from "@/requestRouting"; -import { promptTokens, upstreamText, type Filter, type SortDir, type SortKey } from "@/requestTable"; +import { promptTokens, queuedFor, upstreamText, type Filter, type SortDir, type SortKey } from "@/requestTable"; +import { resumeClock } from "@/upstreams/throttle"; import type { ContentHit, RequestRow } from "@/types"; import { AliasMark } from "@/aliases/AliasMark"; import { Badge } from "@/ui/badge"; @@ -419,7 +420,8 @@ function requestWidths(r: RequestRow, t: Text, today: number, via: ViaConfig | n const sent = notSent(r); const mark = rowMark(r, via); const phrase = r.local || sent; - const text = sent ? notSentText(sent) : r.local || r.provider ? upstreamText(r) : "—"; + const waiting = queuedFor(r); + const text = sent ? notSentText(sent) : waiting ? t.queuedFor(waiting) : r.local || r.provider ? upstreamText(r) : "—"; // 上游的名字和标志一起封顶;本地应答、没有发往上游的那一句是说明,不截断 const name = (phrase ? textWidth(text) : capped(textWidth(text), NAME_PX.upstream, 22)) + 3; // 那一句可以在词间折行(见 `UpstreamCell`):最窄是最长的那个词 @@ -1163,7 +1165,7 @@ function LatencyCell({ r }: { r: RequestRow }) { /** 状态那一格的字:在跑的写状态码(响应头到了才有),失败、取消写成字 */ function statusText(r: RequestRow, t: Text): string { - if (r.state === "in_flight") return r.status != null ? String(r.status) : "…"; + if (r.state === "in_flight") return r.queued ? t.queued : r.status != null ? String(r.status) : "…"; if (r.state === "failed") return failureKind(r.error) === "aborted" ? t.aborted : t.failed; if (r.state === "cancelled") return t.cancelled; return r.status != null ? String(r.status) : ""; @@ -1196,6 +1198,7 @@ function StatusCell({ r }: { r: RequestRow }) { function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) { const t = useText(trafficText); const sent = notSent(r); + const waiting = queuedFor(r); const mark = rowMark(r, via); const secrets = r.secrets && r.secrets.items.length > 0 ? secretsMark(r.secrets) : null; const edits = contentEdits(r.stripped); @@ -1217,6 +1220,14 @@ function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) { {notSentText(sent)} + ) : waiting ? ( + // 排着队:写在等的那几家(首选的那一家可能正是限流中的),悬停说全和最晚等到什么时候 + <> + + + {t.queuedFor(waiting)} + + ) : ( <> {r.local || !r.provider ? ( diff --git a/src/traffic/Traffic.i18n.tsx b/src/traffic/Traffic.i18n.tsx index 5b73d47b..f65d92c6 100644 --- a/src/traffic/Traffic.i18n.tsx +++ b/src/traffic/Traffic.i18n.tsx @@ -142,6 +142,11 @@ export const trafficText = messages( cancelled: "已取消", /** 在界面上手动中止的 */ aborted: "手动中止", + /** 在跑、但要去的上游此刻发不了(限流中、用量已满、满着),在等其中一家 */ + queued: "排队中", + /** 排队的那一行,上游一格写在等的几家;悬停说全,和最晚等到什么时候 */ + queuedFor: (names: string[]) => names.join("、"), + queuedTip: (names: string[], at: string) => `排队等待上游:${names.join("、")}。最晚等到 ${at}。`, // 上游一列的徽标和它们的悬浮说明 /** 客户端写的是别名,按这家上游换成了它的模型名。模型那一列照旧是客户端写的名称 */ @@ -291,6 +296,9 @@ export const trafficText = messages( failed: "Failed", cancelled: "Canceled", aborted: "Aborted", + queued: "Queued", + queuedFor: (names: string[]) => names.join(", "), + queuedTip: (names: string[], at: string) => `Queued for ${names.join(", ")}. Waits until ${at} at most.`, aliasTip: (model: string, upstream: string, sent: string) => `${model} is an alias; the model sent to ${upstream} is ${sent}.`, diff --git a/src/traffic/cells.tsx b/src/traffic/cells.tsx index a6c8bf93..51ed6e14 100644 --- a/src/traffic/cells.tsx +++ b/src/traffic/cells.tsx @@ -101,12 +101,16 @@ const NOT_SENT: Record = { unavailable: { Icon: IconNoUpstream, color: "text-muted-foreground" }, limited: { Icon: IconLimitReached, color: "text-warning" }, busy: { Icon: IconBusy, color: "text-muted-foreground" }, + throttled: { Icon: IconBusy, color: "text-muted-foreground" }, + full: { Icon: IconLimitReached, color: "text-warning" }, + timeout: { Icon: IconBusy, color: "text-muted-foreground" }, }; /** * 没有上游接下的请求在上游标志的位置上画什么:被规则拒绝是禁止符号(和路由图上 * 「拒绝」那个节点同一个),没有可用的上游是划掉的上游,密钥的用量到了上限是顶到线的 - * 箭头,上游都满着是沙漏。和上游标志一样大,名字对得齐。 + * 箭头,上游都满着是沙漏。上游都在限流暂停、几种都有的同样是沙漏(等过了没等到),上游 + * 到了自己的用量上限和密钥的一样是顶到线的箭头。和上游标志一样大,名字对得齐。 * * 拒绝带红色,和路由图一致;用量上限是琥珀色:到了上限要留意,但不是故障。`plain` 时 * 跟着周围的字色(命令面板里的图标都是单色)。 diff --git a/src/traffic/drawer/RequestDrawer.i18n.tsx b/src/traffic/drawer/RequestDrawer.i18n.tsx index 0eb2b00c..bd23469d 100644 --- a/src/traffic/drawer/RequestDrawer.i18n.tsx +++ b/src/traffic/drawer/RequestDrawer.i18n.tsx @@ -130,9 +130,17 @@ export const requestDrawerText = messages( unavailable: "规则选中的上游均无法服务此请求,未发往任何上游。", /** 前面几跳里有满着跳过的:它们不是上游的失败 */ switched: (n: number) => `已自动切换上游:前 ${n} 次尝试未接下此请求。`, + /** 最后接下它的是前面试过的一家:等它恢复之后发了过去 */ + waited: (n: number) => `前 ${n} 次尝试未接下此请求,上游恢复后已发出。`, limited: "网关密钥已达到用量上限,此请求未发往任何上游。", busy: "上游均已达到并发上限,等待期间没有空出位置,此请求未发往任何上游。", busyAfterTries: (n: number) => `发出的 ${n} 次尝试未成功,其余上游均已达到并发上限,等待期间没有空出位置。`, + throttled: "上游均因返回 429 而暂停,等待期间没有恢复,此请求未被接下。", + throttledAfterTries: (n: number) => `发出的 ${n} 次尝试未成功,其余上游均因返回 429 而暂停,等待期间没有恢复。`, + full: "上游均已达到各自的用量上限,此请求未被接下。", + fullAfterTries: (n: number) => `发出的 ${n} 次尝试未成功,其余上游均已达到各自的用量上限。`, + timeout: "等待期间没有上游恢复可用,此请求未被接下。", + timeoutAfterTries: (n: number) => `发出的 ${n} 次尝试未成功,等待期间其余上游均未恢复可用。`, noRouting: "此请求由网关本地应答,未经过路由。", routingPending: "路由尚未完成", noAttempts: "此请求没有上游尝试记录。", @@ -296,12 +304,31 @@ export const requestDrawerText = messages( n === 1 ? "Switched upstreams automatically: the first attempt did not take this request." : `Switched upstreams automatically: the first ${n} attempts did not take this request.`, + waited: (n: number) => + n === 1 + ? "The first attempt did not take this request; it was sent once the upstream was available again." + : `The first ${n} attempts did not take this request; it was sent once the upstream was available again.`, limited: "The gateway key had reached a usage limit; this request was not sent to any upstream.", busy: "Every upstream was at its concurrency limit and none freed up in time; this request was not sent to any upstream.", busyAfterTries: (n: number) => n === 1 ? "The attempt sent did not succeed, and the other upstreams were at their concurrency limits with none freeing up in time." : `The ${n} attempts sent did not succeed, and the other upstreams were at their concurrency limits with none freeing up in time.`, + throttled: "Every upstream was paused after answering 429 and none resumed in time; this request was not taken.", + throttledAfterTries: (n: number) => + n === 1 + ? "The attempt sent did not succeed, and the other upstreams were paused after answering 429 with none resuming in time." + : `The ${n} attempts sent did not succeed, and the other upstreams were paused after answering 429 with none resuming in time.`, + full: "Every upstream was at one of its usage limits; this request was not taken.", + fullAfterTries: (n: number) => + n === 1 + ? "The attempt sent did not succeed, and the other upstreams were at their usage limits." + : `The ${n} attempts sent did not succeed, and the other upstreams were at their usage limits.`, + timeout: "No upstream became available in time; this request was not taken.", + timeoutAfterTries: (n: number) => + n === 1 + ? "The attempt sent did not succeed, and none of the other upstreams became available in time." + : `The ${n} attempts sent did not succeed, and none of the other upstreams became available in time.`, noRouting: "The gateway answered this request locally; it did not go through routing.", routingPending: "Routing has not finished yet", noAttempts: "No upstream attempts were recorded for this request.", diff --git a/src/traffic/drawer/Routing.tsx b/src/traffic/drawer/Routing.tsx index 3c7c3743..46e53fb0 100644 --- a/src/traffic/drawer/Routing.tsx +++ b/src/traffic/drawer/Routing.tsx @@ -85,7 +85,7 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi
    {f.hops.map(({ attempt: a, denied }, i) => { const outcome = attemptText(a); - // 没有发给这个上游的一跳:被规则拒绝,或者它满着、换了下一家 + // 没有发给这个上游的一跳:被规则拒绝,或者它满着、限流中、用量已满,换了下一家 const unsent = denied || skippedHop(a); return (
  1. @@ -124,7 +124,7 @@ export function Routing({ r, plugins, running }: { r: HistoryRow; plugins: Plugi )} )} - {/* 等空位的时间不算在这一跳的耗时里,另写一项 */} + {/* 排队的时间(等空位、等限流或用量恢复)不算在这一跳的耗时里,另写一项 */} {a.queued_ms != null && a.queued_ms > 0 && ( {t.queued(seconds(a.queued_ms))} @@ -227,10 +227,18 @@ function noteText(n: RoutingNote, t: (typeof requestDrawerText)["zh"]): string { return t.failover(n.failed); case "switched": return t.switched(n.count); + case "waited": + return t.waited(n.count); case "limited": return t.limited; case "busy": return n.tried > 0 ? t.busyAfterTries(n.tried) : t.busy; + case "throttled": + return n.tried > 0 ? t.throttledAfterTries(n.tried) : t.throttled; + case "full": + return n.tried > 0 ? t.fullAfterTries(n.tried) : t.full; + case "timeout": + return n.tried > 0 ? t.timeoutAfterTries(n.tried) : t.timeout; case "failover_denied": return t.failoverDenied(n.failed, n.rule); case "denied_after_pick": diff --git a/src/types.ts b/src/types.ts index 64fde3e9..b102e705 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,6 +108,14 @@ export interface RequestRow { * 那一刻。 */ state: "in_flight" | "done" | "failed" | "cancelled"; + /** + * 正在排队:要去的上游此刻发不了(限流中、用量已满、满着),在等其中一家先空出来,最多等到 + * `untilMs`。`request_queued` 到了才有;不排了(`request_dequeued`:等到了、被中止了、等不到 + * 了)、路由出了结论(`request_routed`)或者请求结束就撤掉。**路由事件要等整条尝试链走完**, + * 等到了、正在发的那一段靠 `request_dequeued` 撤。发出去又收到 429、回去接着等的,会再来一条 + * `request_queued` + */ + queued?: { upstreams: string[]; untilMs: number }; status?: number; /** 响应头到的时刻 */ ttfbMs?: number; @@ -224,10 +232,21 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { if (r) r.ttftMs = ev.ttft_ms; break; } + case "request_queued": { + const r = rows.get(ev.id); + if (r) r.queued = { upstreams: [...ev.upstreams], untilMs: ev.until_ms }; + break; + } + case "request_dequeued": { + const r = rows.get(ev.id); + if (r) r.queued = undefined; + break; + } case "request_finished": { const r = rows.get(ev.id); if (r) { r.state = "done"; + r.queued = undefined; r.status = ev.status; r.sentBytes = ev.sent_bytes; r.receivedBytes = ev.received_bytes; @@ -246,6 +265,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { const r = rows.get(ev.id); if (r) { r.state = "cancelled"; + r.queued = undefined; // 响应头之前就断开的没有状态码 —— 那就保留原样(也是没有) if (ev.status != null) r.status = ev.status; // 还没发给上游就走了的没有(不是 0) @@ -273,6 +293,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { r.sentModel = last.model ?? undefined; } if (r) { + r.queued = undefined; r.route = ev.route; r.rule = ev.rule; // 选定上游之后才判断的改写在这里才有 @@ -298,6 +319,8 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { case "sync_status": case "gateway_keys_changed": case "client_prefs_changed": + // 上游限流、用量满了又恢复:上游表那一行的状态,不是哪一次请求的事 + case "throttle_changed": // 都不进请求列表。配置事件、熔断、额度、余额、凭据、代理说的都是「现在什么情况」, // 而这张表装的是「刚才发生过什么」。App 单独接。 break; @@ -350,6 +373,7 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { const r = rows.get(ev.id); if (r) { r.state = "failed"; + r.queued = undefined; r.error = ev.message; if (ev.duration_ms != null) r.durationMs = ev.duration_ms; // 一跳都没发出去的失败没有(被规则拒绝、上游都满着……) diff --git a/src/upstreams/ChatgptAccountSection.tsx b/src/upstreams/ChatgptAccountSection.tsx index 5acb1047..99d5dcb5 100644 --- a/src/upstreams/ChatgptAccountSection.tsx +++ b/src/upstreams/ChatgptAccountSection.tsx @@ -26,6 +26,7 @@ import { commonText } from "@/i18n/common.i18n"; import { api } from "./api"; import { chatgptAccountText } from "./ChatgptAccountSection.i18n"; import { ConcurrencyField } from "./ConnectionSection"; +import { LimitsField } from "./LimitsField"; import { coreText, errorText, planLabel, proxyKindLabel, quotaWindowBefore } from "./labels"; import { DialogError, FormItem } from "./parts"; import { QuotaBar } from "./QuotaBar"; @@ -147,6 +148,8 @@ export function ChatgptAccountSection({
+ + {!broken && ( diff --git a/src/upstreams/ConnectionSection.i18n.ts b/src/upstreams/ConnectionSection.i18n.ts index d23d2ca2..f640c890 100644 --- a/src/upstreams/ConnectionSection.i18n.ts +++ b/src/upstreams/ConnectionSection.i18n.ts @@ -43,6 +43,7 @@ export const connectionSectionText = messages( failWithError: "返回错误", fallBackDirect: "改为直连", concurrency: "并发上限", + limits: "用量上限", balanceSource: "余额来源", balanceAuto: "自动识别", balanceOff: "不读取", @@ -106,6 +107,7 @@ export const connectionSectionText = messages( failWithError: "Return an error", fallBackDirect: "Connect directly", concurrency: "Concurrency limit", + limits: "Usage limits", balanceSource: "Balance source", balanceAuto: "Auto-detect", balanceOff: "Do not read", diff --git a/src/upstreams/ConnectionSection.tsx b/src/upstreams/ConnectionSection.tsx index 800023c8..ea29f224 100644 --- a/src/upstreams/ConnectionSection.tsx +++ b/src/upstreams/ConnectionSection.tsx @@ -26,6 +26,7 @@ import { useSystemProxyLabel } from "@/connection/Remote"; import { useRemote } from "@/connection/useRemote"; import { remoteText } from "@/connection/remote.i18n"; import { HeaderEditor, type AuthRow } from "./HeaderEditor"; +import { LimitsField } from "./LimitsField"; import { BEDROCK_REGIONS, PROTOCOLS, @@ -130,7 +131,7 @@ export interface ZaiAccount { /** * 连接:名称和出站代理一行,地址和协议、区域一行(协议、区域只在有得选时出现,地址下面一句说明), - * 凭据,最后是收起的「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限)。新建和 + * 凭据,最后是收起的「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限、用量上限)。新建和 * 编辑是同一张表单;新建时第一步选的服务类型已经定了的几项(地址、协议、要不要密钥)不再问, * 见 `presets.ts`。 * @@ -285,7 +286,12 @@ export function ConnectionSection({ )} - + } + > {/* 连着远程 core 时 `${变量名}` 读的是服务器上的环境 */} ) => void; rules: FieldRules; + /** 用量上限那一块,在并发上限那几项下面 */ + limits: ReactNode; /** 请求头那一项:它跟着连接一节里密钥的显示开关 */ children: ReactNode; }) { @@ -336,6 +345,7 @@ function AdvancedSettings({ t.onProxyFail, t.concurrency, rules.balance ? t.balanceSource : null, + t.limits, ].filter((x): x is string => x !== null); return (
@@ -353,7 +363,7 @@ function AdvancedSettings({ {/* 展开时一项接一项:请求头整行,转发客户端身份和它的说明,最后是几项短的(代理不可用时、 - 并发上限,自定义还有余额来源)两两并排 + 并发上限,自定义还有余额来源)两两并排,用量上限整行在最后 */} {open && (
@@ -402,6 +412,7 @@ function AdvancedSettings({ )}
+ {limits}
)}
diff --git a/src/upstreams/LimitsField.i18n.ts b/src/upstreams/LimitsField.i18n.ts new file mode 100644 index 00000000..3455cb80 --- /dev/null +++ b/src/upstreams/LimitsField.i18n.ts @@ -0,0 +1,12 @@ +import { messages } from "@/i18n"; + +export const limitsFieldText = messages( + { + hint: "任一上限用满后,此上游暂不接收新请求", + free: "此上游不计费,费用按 0 计入,费用上限不会用满。", + }, + { + hint: "Once any limit is used up, this upstream takes no new requests for a while", + free: "This upstream is free: its cost counts as $0, so a cost limit is never used up.", + }, +); diff --git a/src/upstreams/LimitsField.tsx b/src/upstreams/LimitsField.tsx new file mode 100644 index 00000000..74ce282f --- /dev/null +++ b/src/upstreams/LimitsField.tsx @@ -0,0 +1,40 @@ +import { useText } from "@/i18n"; +import { limitProblems } from "@/keys/limits"; +import { LimitsEditor } from "@/keys/LimitsEditor"; +import type { ProviderView } from "@/types"; +import { limitsFieldText } from "./LimitsField.i18n"; +import { Note } from "./parts"; +import type { UpstreamForm } from "./upstreamForm"; + +/** + * 上游的「用量上限」:和密钥对话框同一个编辑器(`LimitsEditor`),放在并发上限下面。 + * + * 编辑已保存的上游时,每一行下面是 core 数的此刻用量(`ProviderView::limits`)。不计费的 + * 上游费用记 0:有费用上限时说一句它不会用满。填错的当场说,保存按不下去(`limitsMissing`) + */ +export function LimitsField({ + form, + set, + editing, + rowDays, +}: { + form: UpstreamForm; + set: (patch: Partial) => void; + editing: ProviderView | null; + /** 请求记录留几天(概览里的)。天、周、月的上限要求留够那一期 */ + rowDays: number | null; +}) { + const t = useText(limitsFieldText); + const free = form.billing === "free" && form.limits.some((r) => r.measure === "cost"); + return ( + {t.free} : undefined} + onChange={(limits) => set({ limits })} + /> + ); +} diff --git a/src/upstreams/ShareDialog.i18n.ts b/src/upstreams/ShareDialog.i18n.ts index 476e4980..4cb9688b 100644 --- a/src/upstreams/ShareDialog.i18n.ts +++ b/src/upstreams/ShareDialog.i18n.ts @@ -37,6 +37,7 @@ export const shareDialogText = messages( billing: "计费方式", balance: "余额来源", concurrency: "并发上限", + limits: (n: number) => `${n} 条用量上限`, identity: "转发客户端身份", login: "登录方式(接收方须登录)", }, @@ -101,6 +102,7 @@ export const shareDialogText = messages( billing: "billing", balance: "balance source", concurrency: "concurrency limit", + limits: (n: number) => s(n, "usage limit", "usage limits"), identity: "forwarded client identity", login: "sign-in method (the receiver signs in)", }, diff --git a/src/upstreams/ShareDialog.tsx b/src/upstreams/ShareDialog.tsx index 67465dbb..5b0ead80 100644 --- a/src/upstreams/ShareDialog.tsx +++ b/src/upstreams/ShareDialog.tsx @@ -162,6 +162,8 @@ function Summary({ carried: items, left, dropped }: { carried: Carried[]; left: return c.specs(x.n); case "prices": return c.prices(x.n); + case "limits": + return c.limits(x.n); default: return c[x.kind]; } diff --git a/src/upstreams/ThrottleTag.tsx b/src/upstreams/ThrottleTag.tsx new file mode 100644 index 00000000..96905938 --- /dev/null +++ b/src/upstreams/ThrottleTag.tsx @@ -0,0 +1,81 @@ +import { useText } from "@/i18n"; +import { limitsText } from "@/keys/limits.i18n"; +import { amount, resetText } from "@/keys/limits"; +import { StatusDot, StatusLabel } from "@/ui/status-dot"; +import { Tip } from "@/ui/tip"; +import { useNow } from "@/useNow"; +import type { ProviderView, Throttle } from "@/types"; +import { periodText, resumeClock, throttleLabel, type UsageLimit } from "./throttle"; +import { throttleText } from "./throttle.i18n"; + +/** + * 上游表那一行名字旁边的「限流中 · 42 秒后恢复」「用量已满 · 1 分 10 秒后恢复」「今日用量已满」。 + * + * **是状态,不是警告**:琥珀色的点和字,和「熔断中」同一个位置,但不进页头的「要处理」, + * 不弹通知。倒数每秒走一格,到点就消失 —— 不等 core 再报一条;core 先报了「又可以发了」 + * (`throttle_changed`,空)就当场消失。 + * + * 只给有状态的行挂上它:每秒一次的定时器只在这几行上走。 + */ +export function ThrottleTag({ p, throttle }: { p: ProviderView; throttle: Throttle }) { + const now = useNow(1_000); + const label = throttleLabel(p, throttle, now); + if (!label) return null; + return ( + /* + **名字不让位**:这一格从零宽起、只占名字和别的标记剩下的地方(`basis-0 flex-1`),放不下 + 就截断它自己,名字照常写全。悬停只挂在字上,不挂在占满的那一格上 + */ + + }> + + {/* 数字等宽:倒数时字不左右晃 */} + + {label} + + + + + + ); +} + +/** 悬停:什么时候恢复发送;用量满了的,下面列出每条上限此刻用了多少 */ +function ThrottleTip({ p, throttle, now }: { p: ProviderView; throttle: Throttle; now: number }) { + const t = useText(throttleText); + const at = resumeClock(throttle.until_ms, now); + if (throttle.kind === "rate_limited") return

{t.rateLimitedTip(at)}

; + return ( +
+

{t.resumesAt(at)}

+ +
+ ); +} + +/** + * 一个上游的每条用量上限此刻用了多少,一条一行:「今天 $1.23 / $5.00 · 00:00 重置」 + * 「最近 5 分钟 12 / 30」。和密钥对话框里用量那一行同一个写法;到了的后面写「已达上限」 + * (悬停是反色的底,不再上琥珀色)。 + * 没有上限的不画。 + */ +export function LimitUsage({ limits, now }: { limits: readonly UsageLimit[]; now: number }) { + const t = useText(throttleText); + const w = useText(limitsText); + if (limits.length === 0) return null; + return ( +
+

{t.limitsTitle}

+ {limits.map((l, i) => { + const resets = l.resets_at_ms != null && l.resets_at_ms > now ? resetText(l.resets_at_ms, now) : null; + return ( +

+ {periodText(l)} {amount(l.measure, l.used)} / {amount(l.measure, l.max)} + {resets && ` · ${resets}`} + {l.reached && ` · ${w.reached}`} +

+ ); + })} +
+ ); +} diff --git a/src/upstreams/UpstreamDialog.tsx b/src/upstreams/UpstreamDialog.tsx index f5ea648a..4e7178a8 100644 --- a/src/upstreams/UpstreamDialog.tsx +++ b/src/upstreams/UpstreamDialog.tsx @@ -60,6 +60,7 @@ import { blankForm, connectionChanged, connectionMissing, + limitsMissing, describeModelList, fieldRules, formFromDraft, @@ -526,20 +527,26 @@ export function UpstreamDialog({ } : undefined; + const rowDays = ov.retention.row_days; const missing = section === "connection" ? signedIn ? ready ? null : t.signingIn - : connectionMissing(form, original, taken) - : section === "models" - ? modelsMissing(form) - : section === "billing" - ? sheetMissing(form, ov.price_sheets) - : null; + : (connectionMissing(form, original, taken) ?? limitsMissing(form, rowDays)) + : section === "account" + ? limitsMissing(form, rowDays) + : section === "models" + ? modelsMissing(form) + : section === "billing" + ? sheetMissing(form, ov.price_sheets) + : null; const blocking = - connectionMissing(form, original, taken) ?? modelsMissing(form) ?? sheetMissing(form, ov.price_sheets); + connectionMissing(form, original, taken) ?? + limitsMissing(form, rowDays) ?? + modelsMissing(form) ?? + sheetMissing(form, ov.price_sheets); /** * 导入链接带的模型规格:最终的模型清单(上游列出的接上手动添加的)里有的才写,别的不写, diff --git a/src/upstreams/UpstreamTable.tsx b/src/upstreams/UpstreamTable.tsx index efceb9a8..86d81f5b 100644 --- a/src/upstreams/UpstreamTable.tsx +++ b/src/upstreams/UpstreamTable.tsx @@ -45,6 +45,7 @@ import { balanceText } from "./balance.i18n"; import { overviewText } from "@/overview/overview.i18n"; import { rankCost } from "@/overview/series"; import { SyncBadges } from "@/sync/UpstreamBadges"; +import { LimitUsage, ThrottleTag } from "./ThrottleTag"; export interface UpstreamActions { edit: (name: string) => void; @@ -75,7 +76,8 @@ export interface UpstreamActions { * 单击一行(或 Enter)打开它的编辑对话框;行尾按钮和右键打开的是同一份操作。 * * 名字旁边的状态只在异常时出现:先是转发不了的(停用、凭据、熔断),再是最近 7 天的 - * 体检标出的偏差(模型名、输入 token、缓存读取),证据在悬停里。 + * 体检标出的偏差(模型名、输入 token、缓存读取),证据在悬停里。此刻发不了、会自己恢复的 + * (限流中、用量已满)另起一个,带倒数(`ThrottleTag`)。 * * 五列是定过的(lite#46):上游、模型、额度 / 计费、24 小时、首 token / 速度。「24 小时」 * 一格里多了一条按小时的走势:请求在一天里怎么分布、失败落在哪几个小时,一眼看得出, @@ -414,6 +416,8 @@ function NameCell({ )} + {/* 限流中、用量已满:会自己恢复的状态,倒数到点就消失。停用的上游不转发,不说 */} + {p.throttle && !p.disabled && }
@@ -587,6 +591,8 @@ function ModelsCell({ */ function QuotaCell({ p, stats, now }: { p: ProviderView; stats: Resource; now: number }) { const t = useText(upstreamTableText); + // 设了用量上限的:悬停时每条上限此刻用了多少,接在额度、余额的悬停后面。停用的不说 + const limits = !p.disabled && p.limits.length > 0 ? : null; // **过了重置时刻的窗口不算数**:手上的百分比是重置之前的,下一个请求才会带来 // 新的。拿它画一根满格的条,说的是一件已经不成立的事 const windows = (stats.data?.quotas?.find((q) => q.provider === p.name)?.windows ?? []).filter( @@ -603,7 +609,7 @@ function QuotaCell({ p, stats, now }: { p: ProviderView; stats: Resource - }> + }>
{/* **要说清楚这个数是用掉的还是剩下的**:一根填了一半的条,两种读法都成立 */} @@ -632,18 +638,19 @@ function QuotaCell({ p, stats, now }: { p: ProviderView; stats: Resource; + if (p.balance && face) return ; const billing = p.billing; - return ( - + const body = ( +
{billingLabel(billing)}
{billing === "per-token" && (
{t.sheet(p.pricing ?? t.defaultSheet)}
)} - +
); + return {limits ? {body} : body}; } /** @@ -663,11 +670,14 @@ function BalanceCell({ balance, face, now, + extra, }: { p: ProviderView; balance: Balance; face: BalanceFace; now: number; + /** 悬停里接在余额后面的(用量上限) */ + extra: ReactNode; }) { const t = useText(upstreamTableText); const bt = useText(balanceText); @@ -679,6 +689,7 @@ function BalanceCell({ {line}

))} + {extra &&
{extra}
}
); const sub = "mt-0.5 w-0 min-w-full truncate tw-label leading-none text-muted-foreground"; @@ -747,8 +758,8 @@ function BalanceCell({ } } -/** 悬停在额度上:每个窗口一行 */ -function QuotaTip({ windows, now }: { windows: QuotaWindow[]; now: number }) { +/** 悬停在额度上:每个窗口一行。设了用量上限的接在后面(`extra`) */ +function QuotaTip({ windows, now, extra }: { windows: QuotaWindow[]; now: number; extra: ReactNode }) { const t = useText(upstreamTableText); return (
@@ -760,6 +771,7 @@ function QuotaTip({ windows, now }: { windows: QuotaWindow[]; now: number }) {
); })} + {extra &&
{extra}
}
); } diff --git a/src/upstreams/UpstreamsPage.tsx b/src/upstreams/UpstreamsPage.tsx index 09775037..7bc35c21 100644 --- a/src/upstreams/UpstreamsPage.tsx +++ b/src/upstreams/UpstreamsPage.tsx @@ -31,6 +31,7 @@ import { statsPartial, useAccountQuotas, useInFlight, + useLiveThrottles, usePricingStatus, useUpstreamHealth, useUpstreamStats, @@ -134,12 +135,14 @@ export default function UpstreamsPage({ * 这里记的是「还没被概览证实的那几个」,概览对上了就撤掉。 */ const [pendingDisabled, setPendingDisabled] = useState>({}); + // 限流中、用量已满:事件一到就画上,不等概览重读 + const live = useLiveThrottles(ov.providers); const providers = useMemo( () => - ov.providers.map((p) => + live.map((p) => p.name in pendingDisabled ? { ...p, disabled: pendingDisabled[p.name]! } : p, ), - [ov.providers, pendingDisabled], + [live, pendingDisabled], ); useEffect(() => { const stale = Object.keys(pendingDisabled).filter((n) => { diff --git a/src/upstreams/data.ts b/src/upstreams/data.ts index c3eed18c..4bfc0624 100644 --- a/src/upstreams/data.ts +++ b/src/upstreams/data.ts @@ -11,9 +11,10 @@ import { bucketStart } from "@/format"; import { presetRange, windowStart } from "@/ui/range"; import { isRunning, parseCoreState } from "@/coreState"; import { useResource, useResources, type Resource } from "@/lib/resource"; -import type { CoreEvent, CostBucketGroup, ProviderModelsView, ProviderView } from "@/types"; +import type { CoreEvent, CostBucketGroup, ProviderModelsView, ProviderView, Throttle } from "@/types"; import { useNow } from "@/useNow"; import { api, type UpstreamStats } from "./api"; +import { applyThrottleEvent, withThrottles } from "./throttle"; /** * 一个上游的模型清单在共用缓存里的键。上游页的模型弹窗、路由的固定模型、测速、价目表 @@ -238,13 +239,16 @@ const ENDED_CAP = 512; * 此刻每个上游有几个请求在途。 * * 开始事件里就有上游;换了上游(转移)以 `request_routed` 的最后一跳为准;三种结局 - * 都算结束。**页面半路挂上**时,已经在飞的那几个没有开始事件可听 —— 挂上时读一次 + * 都算结束。**排着队的不算**(`request_queued` 之后、`request_dequeued` 或 `request_routed` + * 之前):它在等上游空出来,还没有发给哪一家。**页面半路挂上**时,已经在飞的那几个没有开始事件可听 —— 挂上时读一次 * `/in-flight` 的快照,把每个请求到目前为止的事件照原样重放(比快照先到的结束事件 * 记下来,免得补回一个已经结束的)。core 停了、重启了,在途的全部作废:它们不会再有 * 结局。 */ export function useInFlight(): ReadonlyMap { const live = useRef(new Map()); + /** 正在排队的:在 `live` 里,但不算在哪一家头上 */ + const queued = useRef(new Set()); const ended = useRef(new Set()); const [tick, setTick] = useState(0); @@ -254,7 +258,11 @@ export function useInFlight(): ReadonlyMap { /** 开始、路由落到「哪个请求在哪个上游」上。事件流和快照重放用的是同一段 */ const track = (ev: CoreEvent) => { if (ev.kind === "request_started") live.current.set(ev.id, ev.provider); + else if (ev.kind === "request_queued") { + if (live.current.has(ev.id)) queued.current.add(ev.id); + } else if (ev.kind === "request_dequeued") queued.current.delete(ev.id); else if (ev.kind === "request_routed") { + queued.current.delete(ev.id); const last = ev.attempts[ev.attempts.length - 1]; if (last && live.current.has(ev.id)) live.current.set(ev.id, last.provider); } @@ -276,6 +284,7 @@ export function useInFlight(): ReadonlyMap { }; const end = (id: number) => { live.current.delete(id); + queued.current.delete(id); ended.current.add(id); if (ended.current.size > ENDED_CAP) { const oldest = ended.current.values().next().value; @@ -289,6 +298,8 @@ export function useInFlight(): ReadonlyMap { if (!ended.current.has(ev.id)) track(ev); break; case "request_routed": + case "request_queued": + case "request_dequeued": track(ev); break; case "request_finished": @@ -303,6 +314,7 @@ export function useInFlight(): ReadonlyMap { }); const unState = subscribe("core-state", (e) => { live.current.clear(); + queued.current.clear(); bump(); if (isRunning(parseCoreState(e.payload))) seed(); }); @@ -316,9 +328,42 @@ export function useInFlight(): ReadonlyMap { return useMemo(() => { const by = new Map(); - for (const provider of live.current.values()) by.set(provider, (by.get(provider) ?? 0) + 1); + for (const [id, provider] of live.current) { + if (!queued.current.has(id)) by.set(provider, (by.get(provider) ?? 0) + 1); + } return by; // `tick` 是 `live` 这份可变状态的版本号 // eslint-disable-next-line react-hooks/exhaustive-deps }, [tick]); } + +/** + * 概览里的上游,换上事件流上刚报来的限流、用量状态(`throttle_changed`)。 + * + * 这条事件也会让外面重读概览(`useRequests` 里的 `upstreamState`),重读回来就以概览为准; + * 这里只管中间那一小会儿:限流常常只有几秒,状态一到就画上,一恢复就撤掉。概览换了一份, + * 先前按事件改的作废。 + */ +export function useLiveThrottles(providers: ProviderView[]): ProviderView[] { + const [overlay, setOverlay] = useState<{ + base: ProviderView[]; + map: ReadonlyMap; + }>(() => ({ base: providers, map: new Map() })); + useEffect( + () => + subscribe("core-event", (e) => { + if (e.payload.kind !== "throttle_changed") return; + const ev = e.payload; + setOverlay((o) => ({ base: o.base, map: applyThrottleEvent(o.map, ev) })); + }), + [], + ); + // 概览重读过:按事件改过的那几家以它为准 + const map = overlay.base === providers ? overlay.map : EMPTY_OVERLAY; + useEffect(() => { + if (overlay.base !== providers) setOverlay({ base: providers, map: new Map() }); + }, [providers, overlay.base]); + return useMemo(() => withThrottles(providers, map), [providers, map]); +} + +const EMPTY_OVERLAY: ReadonlyMap = new Map(); diff --git a/src/upstreams/importForm.test.ts b/src/upstreams/importForm.test.ts index fbd07d88..7e329c4a 100644 --- a/src/upstreams/importForm.test.ts +++ b/src/upstreams/importForm.test.ts @@ -40,6 +40,7 @@ function proposal(patch: Partial): ImportProposal { headers: [], client_identity: false, concurrency: null, + limits: [], balance: null, models: [], models_only: null, @@ -59,6 +60,10 @@ const relay = proposal({ headers: [{ name: "X-Group", value: "vip" }], client_identity: true, concurrency: 8, + limits: [ + { per: "minute", every: 5, measure: "tokens", max: 10_000_000, cache_reads: false }, + { per: "day", every: 1, measure: "cost", max: 20_000_000, cache_reads: false }, + ], models: ["gpt-5", "gpt-5-mini"], models_only: ["gpt-5*"], specs: [{ model: "gpt-5", context_window: 400000, max_output_tokens: null, reasoning: true, image_input: null }], @@ -91,6 +96,10 @@ describe("按导入链接预填新建对话框", () => { it("中转站给齐的几项都进表单,交给 core 的也是这几项", () => { const f = formFromImport(relay, []); + expect(f.limits.map((r) => [r.per, r.every, r.measure, r.max])).toEqual([ + ["minute", "5", "tokens", "10000000"], + ["day", "1", "cost", "20.00"], + ]); expect(f).toMatchObject({ preset: "sub2api", maxConcurrent: "8", @@ -110,6 +119,7 @@ describe("按导入链接预填新建对话框", () => { headers: [{ name: "X-Group", value: "vip" }], forward_client_identity: true, max_concurrent: 8, + limits: relay.limits, models: ["gpt-5", "gpt-5-mini"], models_only: ["gpt-5*"], billing: "per-token", diff --git a/src/upstreams/labels.i18n.ts b/src/upstreams/labels.i18n.ts index c43a0c8a..9151d59e 100644 --- a/src/upstreams/labels.i18n.ts +++ b/src/upstreams/labels.i18n.ts @@ -66,6 +66,12 @@ export const labelsText = messages( not_offered: "未提供此模型", not_allowed: "密钥不允许使用此模型", busy: "并发已满", + /** 上游回了 429,暂停发送中 */ + throttled: "限流中", + /** 分钟、小时的用量上限已满 */ + window_full: "用量已满", + /** 天、周、月的用量上限已满,到下一期才恢复 */ + period_used_up: "本期用量已满", }, defaultSheet: "默认价目表", unpriced: "无法计价", @@ -150,6 +156,9 @@ export const labelsText = messages( not_offered: "Model not offered", not_allowed: "Model not allowed for this key", busy: "At its concurrency limit", + throttled: "Rate limited", + window_full: "Usage limit reached", + period_used_up: "Usage limit reached for this period", }, defaultSheet: "Default price sheet", unpriced: "Unpriced", diff --git a/src/upstreams/labels.ts b/src/upstreams/labels.ts index 0bc7dc6d..f113439a 100644 --- a/src/upstreams/labels.ts +++ b/src/upstreams/labels.ts @@ -354,6 +354,12 @@ export function skipLabel(reason: ServeSkip): string { return t.not_allowed; case "busy": return t.busy; + case "throttled": + return t.throttled; + case "window_full": + return t.window_full; + case "period_used_up": + return t.period_used_up; } } diff --git a/src/upstreams/share.test.ts b/src/upstreams/share.test.ts index 0abd3fda..91ba242e 100644 --- a/src/upstreams/share.test.ts +++ b/src/upstreams/share.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; import type { ModelRow, PriceSheetInput, ProviderView } from "@/types"; +import { inputsOf } from "@/keys/limits"; import { presetById } from "./presets"; import { carried, dropParam, shareProposal } from "./share"; import { authModeOf, formFromImport, formFromView, type UpstreamForm } from "./upstreamForm"; @@ -31,6 +32,7 @@ function view(patch: Partial = {}): ProviderView { billing: "per-token", references: [], pricing: null, + limits: [], balance_setting: "auto", balance: null, signed_in: null, @@ -58,6 +60,7 @@ function shape(f: UpstreamForm) { aws: authModeOf(f) === "aws-keys" ? [f.awsKeyId, f.awsSecret, f.awsToken, f.awsRegion] : null, identity: f.forwardClientIdentity, concurrency: f.maxConcurrent, + limits: inputsOf(f.limits), manual: f.manualModels, scope: f.scope, scopeList: f.scopeList, @@ -82,6 +85,15 @@ describe("分享上游:链接里的提议", () => { roundTrips(view({ name: "ollama", base_url: "http://127.0.0.1:11434", key: null, headers: [], protocol: null, protocol_explicit: false, billing: "free" })); roundTrips(view({ name: "lan-ollama", base_url: "http://192.168.1.20:11434", key: null, headers: [], protocol: null, protocol_explicit: false, billing: "per-token" })); roundTrips(view({ balance_setting: "newapi", protocol: "openai-chat" })); + roundTrips( + view({ + limits: [ + { per: "minute", every: 5, measure: "tokens", max: 10_000_000, cache_reads: false, used: 4_200_000, reached: false }, + { per: "day", every: 1, measure: "cost", max: 5_500_000, cache_reads: false, used: 0, resets_at_ms: 1, reached: false }, + { per: "week", every: 1, measure: "tokens", max: 900_000, cache_reads: true, used: 0, reached: false }, + ], + }), + ); roundTrips(view({ balance_setting: "openrouter", protocol: null, protocol_explicit: false })); roundTrips( view({ @@ -188,6 +200,22 @@ describe("分享上游:链接里的提议", () => { ); }); + it("用量上限照设定写进链接,用量不写;登录账号的不写", () => { + const limits = [ + { per: "minute" as const, every: 5, measure: "tokens" as const, max: 10_000_000, cache_reads: false, used: 9, reached: false }, + ]; + const { proposal } = shareProposal(view({ limits }), NONE, true); + expect(proposal.limits).toEqual([{ per: "minute", every: 5, measure: "tokens", max: 10_000_000, cache_reads: false }]); + expect(carried(proposal)).toContainEqual({ kind: "limits", n: 1 }); + expect(dropParam(proposal, "limits")?.limits).toEqual([]); + const zai = shareProposal( + view({ name: "zai", base_url: "https://api.z.ai/api/anthropic", signed_in: "zai", headers: [], limits }), + NONE, + true, + ); + expect(zai.proposal.limits).toEqual([]); + }); + it("写不进链接的那一项去掉;地址、服务类型去不掉", () => { const { proposal } = shareProposal(view({ models: ["m 1"] }), NONE, true); expect(dropParam(proposal, "models")?.models).toEqual([]); diff --git a/src/upstreams/share.ts b/src/upstreams/share.ts index 3913aefe..5b8e5546 100644 --- a/src/upstreams/share.ts +++ b/src/upstreams/share.ts @@ -217,6 +217,11 @@ export function shareProposal(p: ProviderView, extra: ShareExtra, secrets: boole headers: shareHeaders, client_identity: auth !== "account" && f.forwardClientIdentity && preset.clientIdentity === true, concurrency: auth === "account" ? null : (p.max_concurrent ?? null), + // 用量上限和并发上限一样:登录账号的链接不带(接收方登录时由 core 建上游) + limits: + auth === "account" + ? [] + : p.limits.map((l) => ({ per: l.per, every: l.every, measure: l.measure, max: l.max, cache_reads: l.cache_reads })), balance: service === "custom" && p.balance_setting !== "auto" ? p.balance_setting : null, models: p.models, models_only: p.models_only && p.models_only.length > 0 ? p.models_only : null, @@ -259,6 +264,8 @@ export function dropParam(p: ImportProposal, param: string): ImportProposal | nu return { ...p, client_identity: false }; case "concurrency": return { ...p, concurrency: null }; + case "limits": + return { ...p, limits: [] }; case "balance": return { ...p, balance: null }; case "models": @@ -293,6 +300,7 @@ export type Carried = | { kind: "billing" } | { kind: "balance" } | { kind: "concurrency" } + | { kind: "limits"; n: number } | { kind: "identity" } | { kind: "login" }; @@ -315,6 +323,7 @@ export function carried(p: ImportProposal): Carried[] { if (p.billing) out.push({ kind: "billing" }); if (p.balance || preset.balance) out.push({ kind: "balance" }); if (p.concurrency != null) out.push({ kind: "concurrency" }); + if (p.limits.length > 0) out.push({ kind: "limits", n: p.limits.length }); if (p.client_identity) out.push({ kind: "identity" }); return out; } diff --git a/src/upstreams/throttle.i18n.ts b/src/upstreams/throttle.i18n.ts new file mode 100644 index 00000000..96939163 --- /dev/null +++ b/src/upstreams/throttle.i18n.ts @@ -0,0 +1,52 @@ +import { messages } from "@/i18n"; + +/** 英文的单复数 */ +const unit = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + +/** + * 上游此刻发不了(限流中、用量已满)的说法:上游表那一行的状态、悬停,和尝试链里 + * 「429 · 暂停 30 秒」那一跳共用一套时长写法。 + */ +export const throttleText = messages( + { + /** 一段时长:「42 秒」「1 分 10 秒」「2 分钟」「1 小时 5 分」 */ + seconds: (s: number) => `${s} 秒`, + minutes: (m: number) => `${m} 分钟`, + minSec: (m: number, s: number) => `${m} 分 ${s} 秒`, + hours: (h: number) => `${h} 小时`, + hourMin: (h: number, m: number) => `${h} 小时 ${m} 分`, + /** 上游表那一行的状态 */ + rateLimited: (left: string) => `限流中 · ${left}后恢复`, + windowFull: (left: string) => `用量已满 · ${left}后恢复`, + periodUsedUp: { + day: "今日用量已满", + week: "本周用量已满", + month: "本月用量已满", + }, + /** 悬停:什么时候恢复(钟点,一天以外带日期;钟点的写法同密钥的「00:00 重置」) */ + rateLimitedTip: (at: string) => `上游返回 429,${at} 恢复发送。`, + resumesAt: (at: string) => `${at} 恢复发送。`, + /** 悬停里每条上限一行之前的标题 */ + limitsTitle: "用量上限", + /** 尝试链里收到 429、暂停了这家上游的那一跳 */ + paused: (status: number, d: string) => `${status} · 暂停 ${d}`, + }, + { + seconds: (s: number) => `${s} s`, + minutes: (m: number) => `${m} min`, + minSec: (m: number, s: number) => `${m} min ${s} s`, + hours: (h: number) => unit(h, "hour", "hours"), + hourMin: (h: number, m: number) => `${h} h ${m} min`, + rateLimited: (left: string) => `Rate limited · back in ${left}`, + windowFull: (left: string) => `Usage limit reached · back in ${left}`, + periodUsedUp: { + day: "Daily usage limit reached", + week: "Weekly usage limit reached", + month: "Monthly usage limit reached", + }, + rateLimitedTip: (at: string) => `The upstream answered 429. Sending resumes ${at}.`, + resumesAt: (at: string) => `Sending resumes ${at}.`, + limitsTitle: "Usage limits", + paused: (status: number, d: string) => `${status} · Paused ${d}`, + }, +); diff --git a/src/upstreams/throttle.test.ts b/src/upstreams/throttle.test.ts new file mode 100644 index 00000000..7fc7924e --- /dev/null +++ b/src/upstreams/throttle.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { KeyLimitView, ProviderView, Throttle } from "@/types"; +import { + applyThrottleEvent, + durationText, + pausedText, + periodText, + secondsLeft, + throttleLabel, + withThrottles, + type UsageLimit, +} from "./throttle"; + +const NOW = 1_760_000_000_000; + +const limit = (x: Partial & Pick): UsageLimit => ({ + every: 1, + cache_reads: false, + used: 0, + reached: false, + ...x, +}); + +const LIMITS: UsageLimit[] = [ + limit({ per: "minute", measure: "requests", max: 30, used: 30, reached: true, every: 5 }), + limit({ per: "day", measure: "cost", max: 5_000_000, used: 5_000_000, reached: true, resets_at_ms: NOW + 3_600_000 }), + limit({ per: "week", measure: "tokens", max: 1_000_000, used: 1_000_000, reached: true }), + limit({ per: "month", measure: "cost", max: 50_000_000, used: 50_000_000, reached: true }), +]; + +const p = { limits: LIMITS } as Pick; + +describe("倒数的写法", () => { + it("精确到秒,一小时以上写到分钟", () => { + expect(durationText(42)).toBe("42 秒"); + expect(durationText(70)).toBe("1 分 10 秒"); + expect(durationText(120)).toBe("2 分钟"); + expect(durationText(3_900)).toBe("1 小时 5 分"); + expect(durationText(7_200)).toBe("2 小时"); + setLang("en"); + expect(durationText(42)).toBe("42 s"); + expect(durationText(70)).toBe("1 min 10 s"); + expect(durationText(3_600)).toBe("1 hour"); + }); + + it("还差零点几秒也写 1 秒,到点是 0", () => { + expect(secondsLeft(NOW + 200, NOW)).toBe(1); + expect(secondsLeft(NOW, NOW)).toBe(0); + expect(secondsLeft(NOW - 5_000, NOW)).toBe(0); + }); +}); + +describe("上游那一行的状态", () => { + const rate: Throttle = { kind: "rate_limited", until_ms: NOW + 42_000 }; + + it("限流中、用量已满带倒数,每秒都是新的数", () => { + expect(throttleLabel(p, rate, NOW)).toBe("限流中 · 42 秒后恢复"); + expect(throttleLabel(p, rate, NOW + 1_000)).toBe("限流中 · 41 秒后恢复"); + expect(throttleLabel(p, { kind: "window_full", until_ms: NOW + 70_000, limit: 0 }, NOW)).toBe( + "用量已满 · 1 分 10 秒后恢复", + ); + }); + + it("自然周期用完了:今日 / 本周 / 本月,不倒数", () => { + const period = (i: number): Throttle => ({ kind: "period_used_up", until_ms: NOW + 86_400_000, limit: i }); + expect(throttleLabel(p, period(1), NOW)).toBe("今日用量已满"); + expect(throttleLabel(p, period(2), NOW)).toBe("本周用量已满"); + expect(throttleLabel(p, period(3), NOW)).toBe("本月用量已满"); + // 认不出是哪一条(配置刚改过):照滚动窗口那样说什么时候恢复 + expect(throttleLabel(p, { kind: "period_used_up", until_ms: NOW + 60_000, limit: 9 }, NOW)).toBe( + "用量已满 · 1 分钟后恢复", + ); + setLang("en"); + expect(throttleLabel(p, period(1), NOW)).toBe("Daily usage limit reached"); + expect(throttleLabel(p, rate, NOW)).toBe("Rate limited · back in 42 s"); + }); + + it("到点就消失,不等 core 再报", () => { + expect(throttleLabel(p, rate, NOW + 42_000)).toBeNull(); + expect(throttleLabel(p, { kind: "period_used_up", until_ms: NOW, limit: 1 }, NOW)).toBeNull(); + }); + + it("用量那一行开头:每 N 分钟、每 N 小时的写最近 N", () => { + expect(periodText(LIMITS[0]!)).toBe("最近 5 分钟"); + expect(periodText(limit({ per: "hour", measure: "requests", max: 1, every: 2 }))).toBe("最近 2 小时"); + expect(periodText(limit({ per: "minute", measure: "requests", max: 1 }))).toBe("最近一分钟"); + expect(periodText(LIMITS[1]!)).toBe("今天"); + }); +}); + +describe("throttle_changed", () => { + const providers = [{ name: "anthropic" }, { name: "openrouter", throttle: null }] as ProviderView[]; + + it("按事件换上那一家的状态;空 = 又可以发了", () => { + const th: Throttle = { kind: "rate_limited", until_ms: NOW + 30_000 }; + let overlay = applyThrottleEvent(new Map(), { + kind: "throttle_changed", + id: 1, + provider: "anthropic", + throttle: th, + at_ms: NOW, + }); + let out = withThrottles(providers, overlay); + expect(out[0]?.throttle).toEqual(th); + // 没改的那一家是原来的对象 + expect(out[1]).toBe(providers[1]); + overlay = applyThrottleEvent(overlay, { kind: "throttle_changed", id: 2, provider: "anthropic", throttle: null, at_ms: NOW }); + out = withThrottles(providers, overlay); + expect(out[0]?.throttle).toBeNull(); + }); + + it("别的事件、没有改动:原样返回同一个对象", () => { + const empty = new Map(); + expect(applyThrottleEvent(empty, { kind: "models_changed", id: 1, provider: "anthropic", at_ms: NOW })).toBe(empty); + expect(withThrottles(providers, empty)).toBe(providers); + }); +}); + +describe("尝试链里收到 429 的那一跳", () => { + it("写暂停了多久", () => { + expect(pausedText(429, 30_000)).toBe("429 · 暂停 30 秒"); + expect(pausedText(null, 70_000)).toBe("429 · 暂停 1 分 10 秒"); + expect(pausedText(429, null)).toBeNull(); + setLang("en"); + expect(pausedText(429, 5_000)).toBe("429 · Paused 5 s"); + }); +}); diff --git a/src/upstreams/throttle.ts b/src/upstreams/throttle.ts new file mode 100644 index 00000000..4eb091bf --- /dev/null +++ b/src/upstreams/throttle.ts @@ -0,0 +1,129 @@ +/** + * 上游此刻发不了:限流中(上游回了 429)、滚动窗口的用量已满、自然周期的用量已满 + * (`ProviderView.throttle`)。**纯函数**,画的在 `ThrottleTag.tsx`。 + * + * 这是一个状态,不是警告:它会自己恢复,不进通知、不出横幅,只在上游表那一行写着, + * 倒数到点就消失。core 改了状态会报 `throttle_changed`(空 = 又可以发了)。 + */ +import { textOf } from "@/i18n"; +import { periodText as limitPeriodText } from "@/keys/limits"; +import { limitsText } from "@/keys/limits.i18n"; +import type { CoreEvent, KeyLimitView, ProviderView, Throttle } from "@/types"; +import { throttleText } from "./throttle.i18n"; + +/** 一条上游的用量上限:和密钥的是同一个形状 */ +export type UsageLimit = KeyLimitView; + +/** 离 `untilMs` 还有几秒,向上取整:还差 0.2 秒也写「1 秒」,到点了是 0 */ +export function secondsLeft(untilMs: number, nowMs: number): number { + return Math.max(0, Math.ceil((untilMs - nowMs) / 1000)); +} + +/** + * 一段时长写成字:「42 秒」「1 分 10 秒」「2 分钟」「1 小时 5 分」。 + * + * 和额度的「约 3 小时后重置」不同,**这里要精确到秒**:几十秒的限流,「不到一分钟」 + * 说不清要不要等。一小时以上只写到分钟。 + */ +export function durationText(secs: number): string { + const t = textOf(throttleText); + const s = Math.max(0, Math.round(secs)); + if (s < 60) return t.seconds(s); + if (s < 3600) { + const m = Math.floor(s / 60); + const r = s % 60; + return r === 0 ? t.minutes(m) : t.minSec(m, r); + } + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return m === 0 ? t.hours(h) : t.hourMin(h, m); +} + +/** 这一条状态说的是哪一条上限。限流没有;下标越界(配置刚改过)也没有 */ +export function limitOf(p: Pick, th: Throttle): UsageLimit | null { + if (th.limit == null) return null; + return p.limits[th.limit] ?? null; +} + +/** + * 那一行上写什么。到点了(`until_ms` 已过)是 null:状态自己消失,不等 core 再报一条。 + * + * · 限流中 · 42 秒后恢复 + * · 用量已满 · 1 分 10 秒后恢复(分钟、小时的滚动窗口) + * · 今日 / 本周 / 本月用量已满(自然周期,到下一期才恢复,倒数没有意义) + */ +export function throttleLabel(p: Pick, th: Throttle, nowMs: number): string | null { + const left = secondsLeft(th.until_ms, nowMs); + if (left <= 0) return null; + const t = textOf(throttleText); + switch (th.kind) { + case "rate_limited": + return t.rateLimited(durationText(left)); + case "window_full": + return t.windowFull(durationText(left)); + case "period_used_up": { + const per = limitOf(p, th)?.per; + if (per === "day" || per === "week" || per === "month") return t.periodUsedUp[per]; + // 认不出是哪一期:照滚动窗口那样写什么时候恢复 + return t.windowFull(durationText(left)); + } + } +} + +/** 用量那一行开头:「今天」「本周」「最近一分钟」「最近 5 分钟」 */ +export function periodText(l: UsageLimit): string { + return limitPeriodText(l.per, l.every); +} + +const DAY_MS = 24 * 3_600_000; + +/** + * 恢复的时刻:一天之内写到秒(「16:43:07」,限流常常只有几十秒),再远带上日期。写法和 + * 密钥的「00:00 重置」同一套(`limitsText.at` / `on`),按这台机器的时区 + */ +export function resumeClock(atMs: number, nowMs: number): string { + const t = textOf(limitsText); + const d = new Date(atMs); + const two = (n: number) => String(n).padStart(2, "0"); + const hm = `${two(d.getHours())}:${two(d.getMinutes())}`; + if (atMs - nowMs <= DAY_MS) return t.at(`${hm}:${two(d.getSeconds())}`); + const date = new Intl.DateTimeFormat(t.locale, { month: "short", day: "numeric" }).format(d); + return t.on(date, hm); +} + +/** + * 尝试链里收到 429、暂停了这家上游的那一跳:「429 · 暂停 30 秒」。没有暂停的是 null + */ +export function pausedText(status: number | null | undefined, pauseMs: number | null | undefined): string | null { + if (pauseMs == null) return null; + return textOf(throttleText).paused(status ?? 429, durationText(Math.ceil(pauseMs / 1000))); +} + +/** + * `throttle_changed` 到了、概览还没重读的那一会儿:按事件先改。返回新的一份(没关系的 + * 事件原样返回同一个对象)。键是上游名,值是事件里的新状态(null = 又可以发了) + */ +export function applyThrottleEvent( + overlay: ReadonlyMap, + ev: CoreEvent, +): ReadonlyMap { + if (ev.kind !== "throttle_changed") return overlay; + const next = new Map(overlay); + next.set(ev.provider, ev.throttle ?? null); + return next; +} + +/** 概览里的上游,按事件改过的那几家换上事件里的状态。都没改原样返回 */ +export function withThrottles( + providers: ProviderView[], + overlay: ReadonlyMap, +): ProviderView[] { + if (overlay.size === 0) return providers; + let changed = false; + const out = providers.map((p) => { + if (!overlay.has(p.name)) return p; + changed = true; + return { ...p, throttle: overlay.get(p.name) ?? null }; + }); + return changed ? out : providers; +} diff --git a/src/upstreams/upstreamForm.i18n.ts b/src/upstreams/upstreamForm.i18n.ts index 33ec76b4..cc727eee 100644 --- a/src/upstreams/upstreamForm.i18n.ts +++ b/src/upstreams/upstreamForm.i18n.ts @@ -15,6 +15,8 @@ export const upstreamFormText = messages( headerName: "填写请求头名称", headerValue: (header: string) => `填写请求头「${header}」的值`, concurrency: "并发上限须为 1 到 1000 之间的整数", + limitRequired: "填写用量上限的数值", + limitsInvalid: "修正用量上限", sheetName: "填写价目表名称", sheetTaken: (name: string) => `价目表名称「${name}」已被使用`, pickModel: "至少选择一个模型", @@ -37,6 +39,8 @@ export const upstreamFormText = messages( headerName: "Enter a header name", headerValue: (header: string) => `Enter a value for header “${header}”`, concurrency: "The concurrency limit must be a whole number from 1 to 1000", + limitRequired: "Enter an amount for each usage limit", + limitsInvalid: "Fix the usage limits", sheetName: "Enter a price sheet name", sheetTaken: (name: string) => `The price sheet name “${name}” is already in use`, pickModel: "Select at least one model", diff --git a/src/upstreams/upstreamForm.test.ts b/src/upstreams/upstreamForm.test.ts index bf1f7cde..e9c39e41 100644 --- a/src/upstreams/upstreamForm.test.ts +++ b/src/upstreams/upstreamForm.test.ts @@ -15,6 +15,7 @@ import { formFromView, headerRow, inScope, + limitsMissing, modelsMissing, oauthKept, retarget, @@ -59,6 +60,7 @@ function view(patch: Partial = {}): ProviderView { billing: "per-token", references: [], pricing: null, + limits: [], balance_setting: "auto", balance: null, signed_in: null, @@ -601,6 +603,46 @@ describe("交给 core 的定义", () => { }); }); +describe("用量上限", () => { + const limits: ProviderView["limits"] = [ + { per: "minute", every: 5, measure: "tokens", max: 10_000_000, cache_reads: false, used: 1, reached: false }, + { per: "hour", every: 1, measure: "requests", max: 600, cache_reads: false, used: 0, reached: false }, + { per: "month", every: 1, measure: "cost", max: 50_000_000, cache_reads: false, used: 0, resets_at_ms: 9, reached: false }, + ]; + + it("编辑时回填、保存时原样交回,每 N 分钟的 N 不丢", () => { + const p = view({ limits }); + const f = formFromView(p); + expect(f.limits.map((r) => [r.per, r.every, r.measure, r.max])).toEqual([ + ["minute", "5", "tokens", "10000000"], + ["hour", "1", "requests", "600"], + ["month", "1", "cost", "50.00"], + ]); + expect(toInput(f).limits).toEqual(limits.map(({ per, every, measure, max, cache_reads }) => ({ per, every, measure, max, cache_reads }))); + // 改用量上限不算改了连接,模型列表不用重新问 + expect(connectionChanged({ ...f, limits: f.limits.slice(1) }, p)).toBe(false); + }); + + it("没有一条就交空的:删光了就是不限", () => { + expect(toInput(blankForm()).limits).toEqual([]); + expect(toInput({ ...formFromView(view({ limits })), limits: [] }).limits).toEqual([]); + }); + + it("填得不对的当场拦下:空着说填写,别的说修正;月上限要记录留够 31 天", () => { + const f = formFromView(view({ limits })); + expect(limitsMissing(f, 90)).toBeNull(); + expect(limitsMissing(f, 7)).toBe("修正用量上限"); + expect(limitsMissing(f, null)).toBeNull(); + const [first, ...rest] = f.limits; + expect(limitsMissing({ limits: [{ ...first!, max: "" }, ...rest] }, 90)).toBe("填写用量上限的数值"); + expect(limitsMissing({ limits: [{ ...first!, every: "61" }, ...rest] }, 90)).toBe("修正用量上限"); + }); + + it("有用量上限时高级设置自动展开", () => { + expect(advancedNonDefault(formFromView(view({ headers: [], limits })))).toBe(true); + }); +}); + describe("新建时的凭据", () => { it("密钥可以不填:本地服务和用请求头鉴权的中转站都不需要", () => { const f = fresh({}); diff --git a/src/upstreams/upstreamForm.ts b/src/upstreams/upstreamForm.ts index 12d2dd02..5e6db1e0 100644 --- a/src/upstreams/upstreamForm.ts +++ b/src/upstreams/upstreamForm.ts @@ -29,6 +29,7 @@ import type { ProviderView, ZaiFamily, } from "@/types"; +import { inputsOf, limitProblems, rowsOf, type LimitRow } from "@/keys/limits"; import { globMatch } from "./glob"; import { bedrockRegionOf, bedrockUrl } from "./labels"; import { @@ -156,6 +157,8 @@ export interface UpstreamForm { pricing: string; /** 同时最多发给这家几个请求,1 到 1000。空 = 不限 */ maxConcurrent: string; + /** 用量上限(`limits:`),和密钥的同一套编辑器。没有一条 = 不限 */ + limits: LimitRow[]; disabled: boolean; /** * 配置里的 `balance:`。编辑时照原样回填、原样交回 —— 不交就是 `auto`,保存一次就把 @@ -406,6 +409,7 @@ export function blankForm(): UpstreamForm { billing: "per-token", pricing: "", maxConcurrent: "", + limits: [], disabled: false, balance: null, saved: null, @@ -495,6 +499,7 @@ export function formFromView(p: ProviderView): UpstreamForm { billing: p.billing === "free" ? "free" : "per-token", pricing: p.pricing ?? "", maxConcurrent: p.max_concurrent != null ? String(p.max_concurrent) : "", + limits: rowsOf(p.limits), disabled: p.disabled, balance, saved: { @@ -545,6 +550,7 @@ export function formFromImport(p: ImportProposal, taken: string[]): UpstreamForm awsRegion: p.region && bedrockRegionOf(baseUrl) === null ? p.region : "", forwardClientIdentity: p.client_identity, maxConcurrent: p.concurrency != null ? String(p.concurrency) : "", + limits: rowsOf(p.limits), manualModels: p.models, scope: p.models_only ? "some" : "all", scopeList: p.models_only ?? [], @@ -751,13 +757,14 @@ export function toInput(f: UpstreamForm): ProviderInput { billing: f.billing, pricing: f.pricing || undefined, max_concurrent: concurrencyOf(f) ?? undefined, + limits: inputsOf(f.limits), disabled: f.disabled, }; } /** * 「高级设置」里的几项有没有不是默认值的:写了请求头、打开了转发客户端身份、代理不可用时 - * 改为直连、写了并发上限、「自定义」的余额来源不是自动识别。**有就自动展开** —— 收着的一节里 + * 改为直连、写了并发上限或用量上限、「自定义」的余额来源不是自动识别。**有就自动展开** —— 收着的一节里 * 藏着一项在起作用的设置,和没写一样看不见。只有名称或只有值的请求头也算:那一行正等着补全, * 保存按不下去 */ @@ -767,6 +774,7 @@ export function advancedNonDefault(f: UpstreamForm): boolean { f.forwardClientIdentity || f.onProxyFail !== "fail" || f.maxConcurrent.trim() !== "" || + f.limits.length > 0 || (fieldRules(f).balance && (f.balance ?? "auto") !== "auto") ); } @@ -783,6 +791,17 @@ export function concurrencyOf(f: UpstreamForm): number | null | undefined { return n >= 1 && n <= MAX_CONCURRENCY ? n : undefined; } +/** + * 用量上限缺什么:有一条填得不对(`limitProblems`)就说一句,毛病写在那一行下面。只有空着 + * 没填的说「填写」,别的说「有误」。`rowDays`:请求记录留几天,不知道是 null + */ +export function limitsMissing(f: Pick, rowDays: number | null): string | null { + const problems = limitProblems(f.limits, rowDays); + if (problems.size === 0) return null; + const t = textOf(upstreamFormText); + return [...problems.values()].every((p) => p === "required") ? t.limitRequired : t.limitsInvalid; +} + /** * 连接信息和已保存的那一家比改过没有:地址、协议、凭据、请求头、出站代理。 * 改过的话,模型列表要按表单里的新值去问。 diff --git a/src/useRequests.ts b/src/useRequests.ts index 5d99add3..54dd790a 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -205,6 +205,8 @@ function touches(ev: CoreEvent): number | null { case "request_cancelled": case "request_failed": case "request_routed": + case "request_queued": + case "request_dequeued": case "request_priced": case "secrets_found": case "tool_call_flagged": @@ -646,6 +648,8 @@ export function useRequests(ready: boolean) { ev.kind === "proxy_changed" || ev.kind === "credential_expired" || ev.kind === "balance_updated" || + // 上游限流、用量满了或恢复了:概览里的 `throttle` 跟着变 + ev.kind === "throttle_changed" || ev.kind === "events_dropped" ) { setUpstreamState((n) => n + 1); From 9e1880a9139863c1b89d5034a13dd1c10fbb05ac Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:48:12 +0800 Subject: [PATCH 2/2] 2026.10.26: pin core v0.79.0 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- release-notes/2026.10.26.md | 24 ++++++++++++++++ scripts/shots/core/en/status.json | 2 +- scripts/shots/core/zh/status.json | 2 +- src-tauri/Cargo.lock | 46 +++++++++++++++---------------- src-tauri/Cargo.toml | 14 +++++----- src-tauri/tauri.conf.json | 2 +- 7 files changed, 58 insertions(+), 34 deletions(-) create mode 100644 release-notes/2026.10.26.md diff --git a/package.json b/package.json index d549d23e..8dfd1f9e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.25", + "version": "2026.10.26", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.26.md b/release-notes/2026.10.26.md new file mode 100644 index 00000000..a9528a63 --- /dev/null +++ b/release-notes/2026.10.26.md @@ -0,0 +1,24 @@ +**Upgrade notes:** +- The bundled core is now 0.79.0 and the control-plane protocol is 51. A remote server running an older core must be upgraded to 0.79.0 to stay connected. +- Nothing is cleared; the request history, stored payloads and settings carry over. +- Settings > Failover: "Wait for a free slot at most" is now "Wait at most" and covers every kind of wait, with a default of 120 seconds. A configuration that set the old value (`failover.slot_wait_secs`) opens in safe mode; Fix removes it, and the new setting can then be set again. + +**Rate limits no longer stop the agent:** +- When an upstream answers 429, the gateway pauses that upstream for the time it asks for (or backs off 5, 10, 20, 40, then 60 seconds) and holds its requests instead of returning the error. When the pause ends, one request goes first; the rest follow once it gets through. A request that can go to another upstream right away goes there. +- A conversation that stays on an upstream for its prompt cache waits for it when it can recover in time. A single upstream is waited for as well. +- Streaming clients receive their response and keep-alives while they wait, so an agent only sees a slower first token. Only after Settings > Failover > Wait at most does the client get a 429, with the time to retry. +- The Upstreams page tags an upstream that is waiting, such as "Rate limited" or "Usage limit reached", with a countdown. Traffic shows requests that are queued and which upstreams they wait for, and the route view shows each 429 pause. + +**Usage limits on upstreams:** +- The upstream dialog has Usage limits under the concurrency limit: requests, tokens or cost per minute, hour, day, week or month, and per several minutes or hours, for example 10,000,000 tokens every 5 minutes. Gateway keys accept "every N minutes / hours" too. +- When a minute or hour limit is reached, requests go to another upstream or wait. When a day, week or month limit is used up, the upstream is skipped until it resets. +- Shared upstream links and exports carry the limits. + +**Linux:** +- Launch at login works when the AppImage is unpacked, as the AUR package `thinkwatch-lite-bin` does: the login entry starts the AppImage's `AppRun`. Thanks to [@sunnysab](https://github.com/sunnysab) for finding the problem, sending the first fix and testing the final one on Arch (#348). + +**Windows:** +- A plugin's CPU limit no longer counts time the computer was busy with other work. + +**Security:** +- The core's plugin sandbox is updated to Wasmtime 49.0.4 (RUSTSEC-2026-0378, RUSTSEC-2026-0379). diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index babbb27b..7b35176d 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.78.0" + "version": "0.79.0" } diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index babbb27b..7b35176d 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -14,5 +14,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.78.0" + "version": "0.79.0" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fa77ad61..270118b8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -82,7 +82,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -985,7 +985,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1171,7 +1171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2661,7 +2661,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3626,7 +3626,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3682,7 +3682,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4810,7 +4810,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4833,7 +4833,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.25" +version = "2026.10.26" dependencies = [ "anyhow", "base64 0.22.1", @@ -5338,8 +5338,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "serde", "serde_json", @@ -5350,8 +5350,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "memchr", "serde", @@ -5360,8 +5360,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5379,8 +5379,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "serde", "serde_json", @@ -5406,8 +5406,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "serde", "ts-rs", @@ -5415,8 +5415,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "libc", "notify", @@ -5426,8 +5426,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.78.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.78.0#0db59231807168ecddfc90a22eccf5b26f093ea0" +version = "0.79.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.79.0#5c1dd4ba472ad60045cf957fac7a1b1c3630b540" dependencies = [ "saphyr-parser", "thiserror 2.0.21", @@ -5891,7 +5891,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 713ee2b2..f6784fb4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.25" +version = "2026.10.26" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.78.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.79.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.79.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.79.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.79.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.79.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.79.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6c623a67..f4f2b44d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.25", + "version": "2026.10.26", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev",