diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5ed80c9e..295dd9aa 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5438,8 +5438,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "serde", "serde_json", @@ -5450,8 +5450,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "memchr", "serde", @@ -5460,8 +5460,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5480,8 +5480,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "serde", "serde_json", @@ -5507,8 +5507,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "serde", "ts-rs", @@ -5516,8 +5516,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "libc", "notify", @@ -5527,8 +5527,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.86.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.86.0#62aec779f28b8f2f087670cc5096af829bd5ad20" +version = "0.87.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.87.0#b9ea83df081a3911e800938af2e0e5d5002d8c99" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5eac4d51..1a09d41b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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.86.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.87.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.87.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.87.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.87.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.87.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.87.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/import_link.rs b/src-tauri/src/import_link.rs index d17938a2..2b65a74a 100644 --- a/src-tauri/src/import_link.rs +++ b/src-tauri/src/import_link.rs @@ -36,8 +36,8 @@ use serde::Serialize; use tw_api::{BalanceSetting, Billing, LimitMeasure, LimitPer, Protocol, ZaiFamily}; use crate::wire::{ - ImportAuth, ImportAwsKeys, ImportHeader, ImportLimit, ImportLink, ImportOAuth, ImportPrice, - ImportProposal, ImportService, ImportSheet, ImportSpec, ShareLink, + ImportAuth, ImportAwsKeys, ImportHeader, ImportLimit, ImportLink, ImportMinSpeed, ImportOAuth, + ImportPrice, ImportProposal, ImportService, ImportSheet, ImportSpec, ShareLink, }; /// 链接的前缀。主机名那一段不分大小写 @@ -57,6 +57,11 @@ 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; +/// 最低生成速度(token/秒)的上限;下限是 1 +pub const MAX_MIN_SPEED: u32 = 1000; +/// 判定时长(秒)的范围和缺省值,和 core 的 `min_speed.window_secs` 一样 +pub const MIN_SPEED_WINDOW: std::ops::RangeInclusive = 5..=120; +pub const MIN_SPEED_WINDOW_DEFAULT: u32 = 20; /// 用量上限最多几条 pub const MAX_LIMITS: usize = 16; /// 请求数、token 数上限的上限(十五位数) @@ -95,6 +100,8 @@ pub enum Param { Headers, ClientIdentity, Concurrency, + MinSpeed, + MinSpeedWindow, Limits, Balance, Models, @@ -125,6 +132,8 @@ impl Param { Self::Headers, Self::ClientIdentity, Self::Concurrency, + Self::MinSpeed, + Self::MinSpeedWindow, Self::Limits, Self::Balance, Self::Models, @@ -156,6 +165,8 @@ impl Param { Self::Headers => "headers", Self::ClientIdentity => "client_identity", Self::Concurrency => "concurrency", + Self::MinSpeed => "min_speed", + Self::MinSpeedWindow => "min_speed_window", Self::Limits => "limits", Self::Balance => "balance", Self::Models => "models", @@ -855,6 +866,19 @@ fn build( Some(check_concurrency(c)?) } }; + // 判定时长只跟着速度一起给:单给一个时长没有意思 + let min_speed = match params.get(Param::MinSpeed) { + None => { + if params.get(Param::MinSpeedWindow).is_some() { + return Err(Rejected::NotOffered(Param::MinSpeedWindow)); + } + None + } + Some(v) => { + offered(Param::MinSpeed, !account)?; + Some(check_min_speed(v, params.get(Param::MinSpeedWindow))?) + } + }; let limits = match params.get(Param::Limits) { None => Vec::new(), Some(l) => { @@ -928,6 +952,7 @@ fn build( headers, client_identity, concurrency, + min_speed, limits, balance, models, @@ -1232,6 +1257,21 @@ fn check_concurrency(c: &str) -> Result { whole(c, MAX_CONCURRENCY).ok_or(Rejected::Invalid(Param::Concurrency)) } +/// `min_speed=15`(token/秒,1 到 1000),可选 `min_speed_window=20`(秒,5 到 120,不写是 20) +fn check_min_speed(speed: &str, window: Option<&str>) -> Result { + let tokens_per_sec = whole(speed, MAX_MIN_SPEED).ok_or(Rejected::Invalid(Param::MinSpeed))?; + let window_secs = match window { + None => MIN_SPEED_WINDOW_DEFAULT, + Some(w) => whole(w, *MIN_SPEED_WINDOW.end()) + .filter(|n| MIN_SPEED_WINDOW.contains(n)) + .ok_or(Rejected::Invalid(Param::MinSpeedWindow))?, + }; + Ok(ImportMinSpeed { + tokens_per_sec, + window_secs, + }) +} + /// 「每 N」最多写几:分钟 60、小时 24。天、周、月没有 N(core 的 `every` 规则) fn max_every(per: LimitPer) -> Option { match per { @@ -1602,6 +1642,13 @@ pub fn query(p: &ImportProposal) -> String { if let Some(c) = p.concurrency { put(Param::Concurrency, c.to_string()); } + // 判定时长和缺省一样就不写 + if let Some(m) = p.min_speed { + put(Param::MinSpeed, m.tokens_per_sec.to_string()); + if m.window_secs != MIN_SPEED_WINDOW_DEFAULT { + put(Param::MinSpeedWindow, m.window_secs.to_string()); + } + } if !p.limits.is_empty() { let s: Vec = p.limits.iter().map(limit_text).collect(); put(Param::Limits, s.join(";")); diff --git a/src-tauri/src/import_link/tests.rs b/src-tauri/src/import_link/tests.rs index 11db108f..9a0f64e6 100644 --- a/src-tauri/src/import_link/tests.rs +++ b/src-tauri/src/import_link/tests.rs @@ -84,6 +84,7 @@ fn blank(base_url: &str) -> ImportProposal { headers: vec![], client_identity: false, concurrency: None, + min_speed: None, limits: vec![], balance: None, models: vec![], @@ -683,6 +684,7 @@ fn account_links_carry_no_credentials_or_connection_fields() { ("headers", "X-A: 1"), ("protocol", "openai-chat"), ("concurrency", "4"), + ("min_speed", "15"), ("limits", "day|requests|100"), ("oauth_refresh", "r"), ] { @@ -692,6 +694,7 @@ fn account_links_carry_no_credentials_or_connection_fields() { "headers" => Param::Headers, "protocol" => Param::Protocol, "concurrency" => Param::Concurrency, + "min_speed" => Param::MinSpeed, "limits" => Param::Limits, _ => Param::OauthRefresh, }; @@ -1242,6 +1245,75 @@ fn concurrency_is_a_plain_whole_number_up_to_a_thousand() { } } +/// 生成过慢时中断:速度 1 到 1000 token/秒,判定时长 5 到 120 秒、不写是 20 +#[test] +fn min_speed_is_a_rate_with_an_optional_window() { + let got = |extra: &[(&str, &str)]| relay(extra).map(|x| x.min_speed); + assert_eq!(got(&[]), Ok(None)); + assert_eq!( + got(&[("min_speed", "15")]), + Ok(Some(ImportMinSpeed { + tokens_per_sec: 15, + window_secs: 20 + })) + ); + for (v, n) in [("1", 1), ("1000", 1000)] { + assert_eq!( + got(&[("min_speed", v), ("min_speed_window", "60")]), + Ok(Some(ImportMinSpeed { + tokens_per_sec: n, + window_secs: 60 + })) + ); + } + for (w, n) in [("5", 5), ("120", 120)] { + assert_eq!( + got(&[("min_speed", "8"), ("min_speed_window", w)]), + Ok(Some(ImportMinSpeed { + tokens_per_sec: 8, + window_secs: n + })) + ); + } + for bad in ["0", "1001", "01", "+1", "1.5", "八"] { + assert_eq!( + got(&[("min_speed", bad)]), + Err(Rejected::Invalid(Param::MinSpeed)), + "{bad}" + ); + } + for bad in ["0", "4", "121", "020", "20s"] { + assert_eq!( + got(&[("min_speed", "15"), ("min_speed_window", bad)]), + Err(Rejected::Invalid(Param::MinSpeedWindow)), + "{bad}" + ); + } + // 只给判定时长没有意思 + assert_eq!( + got(&[("min_speed_window", "20")]), + Err(Rejected::NotOffered(Param::MinSpeedWindow)) + ); +} + +#[test] +fn min_speed_window_is_written_only_when_not_the_default() { + let at = |window_secs| ImportProposal { + min_speed: Some(ImportMinSpeed { + tokens_per_sec: 15, + window_secs, + }), + ..blank("https://relay.example") + }; + assert_eq!( + generate(&at(20)), + "thinkwatch://import?service=custom&url=https%3A%2F%2Frelay.example&min_speed=15" + ); + assert!(generate(&at(45)).ends_with("&min_speed=15&min_speed_window=45")); + round_trip(&at(20)); + round_trip(&at(45)); +} + /// 用量上限:`周期|计量|上限|N|缓存读取`,和 core 的配置校验同一套规矩 #[test] fn limits_are_checked_like_the_config() { @@ -2005,6 +2077,8 @@ fn every_proposal_the_parser_accepts_round_trips() { ("key", "sk-a+b/c=="), ("client_identity", "on"), ("concurrency", "8"), + ("min_speed", "12"), + ("min_speed_window", "30"), ("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/tests.rs b/src-tauri/src/notices/tests.rs index 90954c16..c1759cde 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -1871,6 +1871,7 @@ fn a_failed_request_is_an_in_app_notice_and_an_abort_is_not() { duration_ms: None, usage: None, answered_model: None, + tokens_per_sec: None, }) }; let s = failed(tw_api::FailureSource::Upstream); diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 0cc4fe8b..4d00090c 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -927,6 +927,16 @@ pub struct ImportLimit { pub cache_reads: bool, } +/// 生成过慢时中断:最近 `window_secs` 秒的生成速度低于 `tokens_per_sec` token/秒时中断 +/// (配置里的 `min_speed`) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +pub struct ImportMinSpeed { + /// 1 到 1000 + pub tokens_per_sec: u32, + /// 判定时长,5 到 120 秒。链接没写是 20 + pub window_secs: u32, +} + /// 链接带来的新价目表:和上游一起创建,名称跟着上游 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] pub struct ImportSheet { @@ -961,6 +971,8 @@ pub struct ImportProposal { pub client_identity: bool, /// 并发上限,1 到 1000 pub concurrency: Option, + /// 生成过慢时中断。null = 不中断 + pub min_speed: Option, /// 用量上限,按链接里的顺序 pub limits: Vec, /// 余额从哪里读(只有「自定义」) diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index 16f43dd2..9fc384f1 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, + min_speed: None, limits: vec![], disabled: false, balance: None, @@ -656,6 +657,7 @@ async fn a_bedrock_upstream_is_saved_the_way_the_dialog_sends_it() { billing: None, pricing: None, max_concurrent: None, + min_speed: None, limits: vec![], disabled: false, balance: None, diff --git a/src/events.test.ts b/src/events.test.ts index c22f9ef5..e88b970c 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -263,6 +263,27 @@ describe("从事件缝出一行", () => { expect(r?.receivedBytes).toBe(480); }); + /** 生成太慢被切断的:失败事件带着判定时的速度,速度一格照常有数 */ + it("生成太慢的失败带上速度", () => { + const rows = new Map(); + applyEvent(rows, started()); + applyEvent(rows, { kind: "request_headers", id: 1, status: 200, ttfb_ms: 900 }); + applyEvent(rows, { + kind: "request_failed", + id: 1, + model: "qwen3", + source: "upstream", + message: plain("`local-vllm` 太慢"), + duration_ms: 31_000, + tokens_per_sec: 9, + usage: { input: 2_000, output: 280, cache_read: 0, cache_write: 0 }, + }); + const r = rows.get(1); + expect(r?.state).toBe("failed"); + expect(r?.tokensPerSec).toBe(9); + expect(r?.outputTokens).toBe(280); + }); + /** 响应头之前就失败的:有耗时,**没有用量就不填** */ it("响应头之前的失败只有耗时", () => { const rows = new Map(); diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index 53e464bf..50637229 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -290,6 +290,20 @@ cache_reads: boolean, }; */ export type ImportLink = { "kind": "proposal", proposal: ImportProposal, } | { "kind": "unsupported", param: string | null, }; +/** + * 生成过慢时中断:最近 `window_secs` 秒的生成速度低于 `tokens_per_sec` token/秒时中断 + * (配置里的 `min_speed`) + */ +export type ImportMinSpeed = { +/** + * 1 到 1000 + */ +tokens_per_sec: number, +/** + * 判定时长,5 到 120 秒。链接没写是 20 + */ +window_secs: number, }; + /** * OAuth 凭据(`auth=oauth`) */ @@ -336,6 +350,10 @@ client_identity: boolean, * 并发上限,1 到 1000 */ concurrency: number | null, +/** + * 生成过慢时中断。null = 不中断 + */ +min_speed: ImportMinSpeed | null, /** * 用量上限,按链接里的顺序 */ diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index f9428025..40a57b9e 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 = 57; +export const CONTROL_API_VERSION = 58; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -1503,7 +1503,13 @@ usage?: UsageView | null, /** * 失败之前上游在回答里写的模型名(见 `RequestFinished::answered_model`) */ -answered_model?: string | null, } | { "kind": "request_cancelled", id: number, +answered_model?: string | null, +/** + * 生成速度,token/秒。**失败的请求一般没有**,只有因为生成太慢被结束的 + * (`gw.upstream.too_slow`,见 [`MinSpeedView`])有:那是结束那一刻判定窗口里的速度, + * 估出来的,和 [`RunningView::tokens_per_sec`] 同一个估法 + */ +tokens_per_sec?: number | null, } | { "kind": "request_cancelled", id: number, /** * 模型名。理由见 `RequestFinished::model` */ @@ -2953,6 +2959,34 @@ case_sensitive?: boolean, } | { "kind": "codepoints", ranges: Array, } | */ export type MedianView = { p50: number, samples: number, }; +/** + * 保存上游时交的最低生成速度([`ProviderInput::min_speed`])。`tokens_per_sec` 是 1 到 1000, + * `window_secs` 是 5 到 120(界面显式填好默认的 20);越界的保存被拒,码是 + * `config.min_speed_range` + */ +export type MinSpeedInput = { tokens_per_sec: number, window_secs: number, }; + +/** + * 一家上游的最低生成速度(配置的 `providers[].min_speed`)。 + * + * 流式回答从第一个 token 起,每秒看一次最近 `window_secs` 秒的平均生成速度(和 + * [`RunningView::tokens_per_sec`] 同一个估法;中途连续 5 秒以上(判定时长不到 20 秒的,它的 + * 四分之一以上)没有内容的那一段不算),低于 `tokens_per_sec` 就结束回答:客户端收到一条 503 的 + * 错误(可以重试),请求失败,码是 `gw.upstream.too_slow`,这家记一次失败。停着(上一段内容 + * 是一秒多以前的)、摘要式的推理(Anthropic、Gemini、Bedrock 的推理,Responses 的推理摘要) + * 进行着、回答说完之后都不判断,推理的那段时间之后也不算。非流式的回答、WebSocket 上的、 + * 不带 `alt=sse` 的 Gemini 流(JSON 数组)不管 + */ +export type MinSpeedView = { +/** + * 每秒至少多少 token + */ +tokens_per_sec: number, +/** + * 按最近多少秒的平均速度判断。配置里没写的是 20 + */ +window_secs: number, }; + /** * 一个没对上的条件。 */ @@ -3885,6 +3919,10 @@ pricing?: string | null, * 同时最多发给这家几个请求,1 到 1000。不给就是不限 */ max_concurrent?: number | null, +/** + * 最低生成速度。不给就是不管 + */ +min_speed?: MinSpeedInput | null, /** * 用量上限,整份替换。不给 = 一条都没有 */ @@ -4124,6 +4162,10 @@ pricing: string | null, * 同时最多发给这家几个请求。不限是空 */ max_concurrent?: number | null, +/** + * 最低生成速度(配置里的 `min_speed`)。不管是空 + */ +min_speed?: MinSpeedView | null, /** * 用量上限,按配置里的顺序,各带此刻用了多少(发出去还没结束的按估算算)。没设的是空的 */ diff --git a/src/i18n/core.zh.cases.json b/src/i18n/core.zh.cases.json index 4150aaf1..15f952e7 100644 --- a/src/i18n/core.zh.cases.json +++ b/src/i18n/core.zh.cases.json @@ -403,5 +403,18 @@ "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」。当天、当周或当月的用量上限已用完:—。达到并发上限:—。请稍后重试。" + }, + { + "msg": { + "code": "gw.upstream.too_slow", + "args": { + "upstream": "local-vllm", + "rate": "9", + "min": "15", + "secs": "20" + }, + "text": "Upstream `local-vllm` generated 9 tokens per second over the last 20 seconds, below the minimum of 15." + }, + "zh": "上游「local-vllm」最近 20 秒的生成速度为 9 token/秒,低于最低生成速度 15 token/秒。" } ] diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 518236d4..bb26e6dd 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -472,6 +472,7 @@ "gw.upstream.stream_ended_early": "上游在给出任何内容之前结束了回答。", "gw.upstream.idle_timeout": "上游「{upstream}」在 {secs} 秒内没有发出内容。", "gw.upstream.idle_timeout_mid_stream": "上游「{upstream}」回答到一半后停止发送内容,{secs} 秒内没有新的内容。", + "gw.upstream.too_slow": "上游「{upstream}」最近 {secs} 秒的生成速度为 {rate} token/秒,低于最低生成速度 {min} token/秒。", "gw.request.aborted": "请求已被手动中止。", "gw.upstream.stream_error": "上游「{upstream}」在回答过程中报错:{message}", "gw.upstream.stream_exception": "上游「{upstream}」以 {kind} 结束了响应流:{message}", @@ -689,6 +690,7 @@ "config.base_url_backslash": "上游「{upstream}」的接口地址含有反斜杠:{url}。地址中请只使用正斜杠。", "config.base_url_userinfo": "上游「{upstream}」的接口地址在主机前带有用户名或密码。凭据请改写在 key 或 headers 中。", "config.zero_concurrency": "网关密钥「{key}」的 max_concurrent 为 0,使用它的请求会一直等待。不限制并发时请删除 max_concurrent。", + "config.min_speed_range": "上游「{upstream}」的 min_speed.{field} 为 {value},须在 {min} 到 {max} 之间。", "config.provider_concurrency_range": "上游「{upstream}」的 max_concurrent 为 {value},须在 1 到 {max} 之间。不限制并发时请删除 max_concurrent。", "config.redact_exceptions_too_many": "security.redact.exceptions 中有 {count} 个值,最多 {max} 个。", "config.redact_exception_hash_bad": "security.redact.exceptions 中的例外「{sha256}」不是 SHA-256:须为 64 个小写十六进制字符。", diff --git a/src/import/services.test.ts b/src/import/services.test.ts index ae8dcf86..26ddf5d1 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, + min_speed: null, limits: [], balance: null, models: [], diff --git a/src/types.ts b/src/types.ts index 62f8d0cb..803e0a79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -391,6 +391,8 @@ export function applyEvent(rows: Map, ev: CoreEvent): void { // 一跳都没发出去的失败没有(被规则拒绝、上游都满着……) if (ev.sent_bytes != null) r.sentBytes = ev.sent_bytes; if (ev.received_bytes != null) r.receivedBytes = ev.received_bytes; + // 生成太慢被切断的失败带着判定时的速度,别的失败没有 + if (ev.tokens_per_sec != null) r.tokensPerSec = ev.tokens_per_sec; // 断在中间的失败,上游已经为这些 token 计了费 if (ev.usage) { r.inputTokens = ev.usage.input; diff --git a/src/upstreams/ChatgptAccountSection.tsx b/src/upstreams/ChatgptAccountSection.tsx index 99d5dcb5..984a64d8 100644 --- a/src/upstreams/ChatgptAccountSection.tsx +++ b/src/upstreams/ChatgptAccountSection.tsx @@ -25,7 +25,7 @@ import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { api } from "./api"; import { chatgptAccountText } from "./ChatgptAccountSection.i18n"; -import { ConcurrencyField } from "./ConnectionSection"; +import { ConcurrencyField, MinSpeedField } from "./ConnectionSection"; import { LimitsField } from "./LimitsField"; import { coreText, errorText, planLabel, proxyKindLabel, quotaWindowBefore } from "./labels"; import { DialogError, FormItem } from "./parts"; @@ -148,6 +148,8 @@ export function ChatgptAccountSection({ + + diff --git a/src/upstreams/ConnectionSection.i18n.ts b/src/upstreams/ConnectionSection.i18n.ts index f640c890..89803888 100644 --- a/src/upstreams/ConnectionSection.i18n.ts +++ b/src/upstreams/ConnectionSection.i18n.ts @@ -50,6 +50,17 @@ export const connectionSectionText = messages( noLimit: "不限", concurrencyDesc: "同时发往此上游的请求数上限,用于限制并发的中转站或账号。", badConcurrency: "须为 1 到 1000 之间的整数。", + minSpeed: "最低生成速度", + /** 「生成速度低于 [ ] token/秒持续 [ ] 秒时中断」,两个输入框把它分成三段 */ + minSpeedBefore: "生成速度低于", + minSpeedMiddle: "token/秒持续", + minSpeedAfter: "秒时中断", + minSpeedRate: "最低生成速度(token/秒)", + minSpeedWindow: "判定时长(秒)", + minSpeedOff: "关闭", + minSpeedDesc: "中断后由客户端重试。", + badMinSpeed: "生成速度须为 1 到 1000 之间的整数。", + badMinSpeedWindow: "判定时长须为 5 到 120 之间的整数。", check: "检测连接", checking: "检测中", checkNote: "验证地址与凭据,并获取模型列表。不产生费用。", @@ -114,6 +125,16 @@ export const connectionSectionText = messages( noLimit: "No limit", concurrencyDesc: "The most requests sent to this upstream at once, for relays and accounts that limit concurrency.", badConcurrency: "A whole number from 1 to 1000.", + minSpeed: "Minimum generation speed", + minSpeedBefore: "Stop when generation is slower than", + minSpeedMiddle: "tokens/s for", + minSpeedAfter: "seconds", + minSpeedRate: "Minimum generation speed (tokens/s)", + minSpeedWindow: "Measuring period (seconds)", + minSpeedOff: "Off", + minSpeedDesc: "The client retries a stopped request.", + badMinSpeed: "The speed must be a whole number from 1 to 1000.", + badMinSpeedWindow: "The measuring period must be a whole number from 5 to 120.", check: "Check connection", checking: "Checking", checkNote: "Verifies the URL and credentials and fetches the model list. No cost is incurred.", diff --git a/src/upstreams/ConnectionSection.tsx b/src/upstreams/ConnectionSection.tsx index ea29f224..abe6798f 100644 --- a/src/upstreams/ConnectionSection.tsx +++ b/src/upstreams/ConnectionSection.tsx @@ -46,6 +46,8 @@ import { authModeOf, concurrencyOf, describeModelList, + minSpeedBad, + minSpeedWindowBad, fieldRules, freeName, isBedrock, @@ -344,6 +346,7 @@ function AdvancedSettings({ rules.clientIdentity ? t.clientIdentity : null, t.onProxyFail, t.concurrency, + t.minSpeed, rules.balance ? t.balanceSource : null, t.limits, ].filter((x): x is string => x !== null); @@ -362,8 +365,8 @@ function AdvancedSettings({ {!open && {inside.join(t.listSep)}} {/* - 展开时一项接一项:请求头整行,转发客户端身份和它的说明,最后是几项短的(代理不可用时、 - 并发上限,自定义还有余额来源)两两并排,用量上限整行在最后 + 展开时一项接一项:请求头整行,转发客户端身份和它的说明,然后是几项短的(代理不可用时、 + 并发上限,自定义还有余额来源)两两并排,生成过慢时中断整行,用量上限整行在最后 */} {open && (
@@ -412,6 +415,7 @@ function AdvancedSettings({ )}
+ {limits} )} @@ -503,6 +507,67 @@ export function ConcurrencyField({ ); } +/** + * 生成过慢时中断:一句话里两个格子,「生成速度低于 [ ] token/秒持续 [20] 秒时中断」。速度空着 + * 是不中断(格子里写着「关闭」);判定时长缺省 20,显式填着。**写错了当场标红**,保存按不下去 + * (`connectionMissing`)。账号上游的「账号」一节也用它 + */ +export function MinSpeedField({ + form, + set, +}: { + form: UpstreamForm; + set: (patch: Partial) => void; +}) { + const t = useText(connectionSectionText); + const badRate = minSpeedBad(form); + const badWindow = minSpeedWindowBad(form); + const numeric = { + inputMode: "numeric", + autoComplete: "off", + autoCorrect: "off", + autoCapitalize: "off", + spellCheck: false, + } as const; + return ( +
+
+ {t.minSpeedBefore} + set({ minSpeed: e.target.value.trim() })} + /> + {t.minSpeedMiddle} + set({ minSpeedWindow: e.target.value.trim() })} + /> + {t.minSpeedAfter} +
+

+ {badRate || badWindow ? ( + + {[badRate && t.badMinSpeed, badWindow && t.badMinSpeedWindow].filter(Boolean).join(" ")} + + ) : ( + t.minSpeedDesc + )} +

+
+ ); +} + /** * 接口协议。`all`:全部,外加自动识别(按地址认,认不出来按客户端发来的格式原样转发); * 给了几种就只在这几种里选(OpenAI 的 Chat Completions 和 Responses) diff --git a/src/upstreams/ShareDialog.i18n.ts b/src/upstreams/ShareDialog.i18n.ts index 4cb9688b..1d851724 100644 --- a/src/upstreams/ShareDialog.i18n.ts +++ b/src/upstreams/ShareDialog.i18n.ts @@ -37,6 +37,7 @@ export const shareDialogText = messages( billing: "计费方式", balance: "余额来源", concurrency: "并发上限", + minSpeed: "最低生成速度", limits: (n: number) => `${n} 条用量上限`, identity: "转发客户端身份", login: "登录方式(接收方须登录)", @@ -102,6 +103,7 @@ export const shareDialogText = messages( billing: "billing", balance: "balance source", concurrency: "concurrency limit", + minSpeed: "minimum generation speed", 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/importForm.test.ts b/src/upstreams/importForm.test.ts index 7e329c4a..b6af98cf 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, + min_speed: null, limits: [], balance: null, models: [], @@ -60,6 +61,7 @@ const relay = proposal({ headers: [{ name: "X-Group", value: "vip" }], client_identity: true, concurrency: 8, + min_speed: { tokens_per_sec: 12, window_secs: 30 }, 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 }, @@ -103,6 +105,8 @@ describe("按导入链接预填新建对话框", () => { expect(f).toMatchObject({ preset: "sub2api", maxConcurrent: "8", + minSpeed: "12", + minSpeedWindow: "30", forwardClientIdentity: true, manualModels: ["gpt-5", "gpt-5-mini"], scope: "some", @@ -119,6 +123,7 @@ describe("按导入链接预填新建对话框", () => { headers: [{ name: "X-Group", value: "vip" }], forward_client_identity: true, max_concurrent: 8, + min_speed: { tokens_per_sec: 12, window_secs: 30 }, limits: relay.limits, models: ["gpt-5", "gpt-5-mini"], models_only: ["gpt-5*"], diff --git a/src/upstreams/share.test.ts b/src/upstreams/share.test.ts index 91ba242e..7a1dc119 100644 --- a/src/upstreams/share.test.ts +++ b/src/upstreams/share.test.ts @@ -60,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, + minSpeed: [f.minSpeed, f.minSpeedWindow], limits: inputsOf(f.limits), manual: f.manualModels, scope: f.scope, @@ -85,6 +86,8 @@ 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({ min_speed: { tokens_per_sec: 15, window_secs: 20 } })); + roundTrips(view({ min_speed: { tokens_per_sec: 3, window_secs: 90 } })); roundTrips( view({ limits: [ diff --git a/src/upstreams/share.ts b/src/upstreams/share.ts index 5b8e5546..7db0816e 100644 --- a/src/upstreams/share.ts +++ b/src/upstreams/share.ts @@ -217,6 +217,8 @@ 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), + // 生成过慢时中断:和并发上限一样,登录账号的链接不带 + min_speed: auth === "account" ? null : (p.min_speed ?? null), // 用量上限和并发上限一样:登录账号的链接不带(接收方登录时由 core 建上游) limits: auth === "account" @@ -264,6 +266,9 @@ export function dropParam(p: ImportProposal, param: string): ImportProposal | nu return { ...p, client_identity: false }; case "concurrency": return { ...p, concurrency: null }; + case "min_speed": + case "min_speed_window": + return { ...p, min_speed: null }; case "limits": return { ...p, limits: [] }; case "balance": @@ -300,6 +305,7 @@ export type Carried = | { kind: "billing" } | { kind: "balance" } | { kind: "concurrency" } + | { kind: "minSpeed" } | { kind: "limits"; n: number } | { kind: "identity" } | { kind: "login" }; @@ -323,6 +329,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.min_speed != null) out.push({ kind: "minSpeed" }); 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/upstreamForm.i18n.ts b/src/upstreams/upstreamForm.i18n.ts index cc727eee..8399a588 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 之间的整数", + minSpeed: "最低生成速度须为 1 到 1000 之间的整数", + minSpeedWindow: "判定时长须为 5 到 120 之间的整数", limitRequired: "填写用量上限的数值", limitsInvalid: "修正用量上限", sheetName: "填写价目表名称", @@ -39,6 +41,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", + minSpeed: "The minimum generation speed must be a whole number from 1 to 1000", + minSpeedWindow: "The measuring period must be a whole number from 5 to 120", limitRequired: "Enter an amount for each usage limit", limitsInvalid: "Fix the usage limits", sheetName: "Enter a price sheet name", diff --git a/src/upstreams/upstreamForm.test.ts b/src/upstreams/upstreamForm.test.ts index e9c39e41..55f0049d 100644 --- a/src/upstreams/upstreamForm.test.ts +++ b/src/upstreams/upstreamForm.test.ts @@ -16,6 +16,7 @@ import { headerRow, inScope, limitsMissing, + minSpeedOf, modelsMissing, oauthKept, retarget, @@ -132,6 +133,37 @@ describe("编辑时回填原样", () => { } }); + it("生成过慢时中断:回填、原样交回;速度清空就是不中断,判定时长缺省 20 显式填着", () => { + const f = formFromView(view({ min_speed: { tokens_per_sec: 15, window_secs: 45 } })); + expect([f.minSpeed, f.minSpeedWindow]).toEqual(["15", "45"]); + expect(toInput(f).min_speed).toEqual({ tokens_per_sec: 15, window_secs: 45 }); + expect(toInput({ ...f, minSpeed: "" }).min_speed).toBeUndefined(); + // 没设过的:速度空着,判定时长写着 20 + for (const g of [formFromView(view()), formFromView(view({ min_speed: null })), blankForm()]) { + expect([g.minSpeed, g.minSpeedWindow]).toEqual(["", "20"]); + expect(toInput(g).min_speed).toBeUndefined(); + } + // 只填速度,判定时长按显示的 20 交 + expect(toInput({ ...blankForm(), minSpeed: "8" }).min_speed).toEqual({ tokens_per_sec: 8, window_secs: 20 }); + }); + + it("生成速度只收 1 到 1000、判定时长只收 5 到 120 的整数,写错了保存不了", () => { + const f = formFromView(view()); + for (const [rate, secs] of [["1", "5"], ["1000", "120"], ["15", "20"], ["", "abc"], ["", ""]] as const) { + expect(connectionMissing({ ...f, minSpeed: rate, minSpeedWindow: secs }, "relay", ["relay"])).toBeNull(); + } + for (const bad of ["0", "1001", "2.5", "-1", "abc"]) { + expect(connectionMissing({ ...f, minSpeed: bad }, "relay", ["relay"])).toBe("最低生成速度须为 1 到 1000 之间的整数"); + expect(minSpeedOf({ ...f, minSpeed: bad })).toBeUndefined(); + } + for (const bad of ["4", "121", "", "20.5", "abc"]) { + expect(connectionMissing({ ...f, minSpeed: "15", minSpeedWindow: bad }, "relay", ["relay"])).toBe( + "判定时长须为 5 到 120 之间的整数", + ); + expect(minSpeedOf({ ...f, minSpeed: "15", minSpeedWindow: bad })).toBeUndefined(); + } + }); + it("地址里有反斜杠、主机前带用户名或密码的,和 core 一样不收", () => { const f = formFromView(view()); expect(connectionMissing({ ...f, baseUrl: "https://relay.example\\v1" }, "relay", ["relay"])).toBe( @@ -867,6 +899,9 @@ describe("高级设置自动展开", () => { expect(advancedNonDefault({ ...f, forwardClientIdentity: true })).toBe(true); expect(advancedNonDefault({ ...f, onProxyFail: "direct" })).toBe(true); expect(advancedNonDefault({ ...f, maxConcurrent: "4" })).toBe(true); + expect(advancedNonDefault({ ...f, minSpeed: "15" })).toBe(true); + // 判定时长只在速度填了时起作用 + expect(advancedNonDefault({ ...f, minSpeedWindow: "60" })).toBe(false); // 写错的并发上限也算:要让人看见那一格才改得了 expect(advancedNonDefault({ ...f, maxConcurrent: "abc" })).toBe(true); }); diff --git a/src/upstreams/upstreamForm.ts b/src/upstreams/upstreamForm.ts index 5e6db1e0..e75ca3ab 100644 --- a/src/upstreams/upstreamForm.ts +++ b/src/upstreams/upstreamForm.ts @@ -157,6 +157,10 @@ export interface UpstreamForm { pricing: string; /** 同时最多发给这家几个请求,1 到 1000。空 = 不限 */ maxConcurrent: string; + /** 生成过慢时中断:最低生成速度,token/秒,1 到 1000。空 = 不按速度中断 */ + minSpeed: string; + /** 判定时长,秒,5 到 120。缺省 20,显式填着;速度空着时不交 */ + minSpeedWindow: string; /** 用量上限(`limits:`),和密钥的同一套编辑器。没有一条 = 不限 */ limits: LimitRow[]; disabled: boolean; @@ -409,6 +413,8 @@ export function blankForm(): UpstreamForm { billing: "per-token", pricing: "", maxConcurrent: "", + minSpeed: "", + minSpeedWindow: String(MIN_SPEED_WINDOW_DEFAULT), limits: [], disabled: false, balance: null, @@ -499,6 +505,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) : "", + ...minSpeedFields(p.min_speed), limits: rowsOf(p.limits), disabled: p.disabled, balance, @@ -550,6 +557,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) : "", + ...minSpeedFields(p.min_speed), limits: rowsOf(p.limits), manualModels: p.models, scope: p.models_only ? "some" : "all", @@ -757,6 +765,7 @@ export function toInput(f: UpstreamForm): ProviderInput { billing: f.billing, pricing: f.pricing || undefined, max_concurrent: concurrencyOf(f) ?? undefined, + min_speed: minSpeedOf(f) ?? undefined, limits: inputsOf(f.limits), disabled: f.disabled, }; @@ -764,7 +773,7 @@ export function toInput(f: UpstreamForm): ProviderInput { /** * 「高级设置」里的几项有没有不是默认值的:写了请求头、打开了转发客户端身份、代理不可用时 - * 改为直连、写了并发上限或用量上限、「自定义」的余额来源不是自动识别。**有就自动展开** —— 收着的一节里 + * 改为直连、写了并发上限、最低生成速度或用量上限、「自定义」的余额来源不是自动识别。**有就自动展开** —— 收着的一节里 * 藏着一项在起作用的设置,和没写一样看不见。只有名称或只有值的请求头也算:那一行正等着补全, * 保存按不下去 */ @@ -774,6 +783,7 @@ export function advancedNonDefault(f: UpstreamForm): boolean { f.forwardClientIdentity || f.onProxyFail !== "fail" || f.maxConcurrent.trim() !== "" || + f.minSpeed.trim() !== "" || f.limits.length > 0 || (fieldRules(f).balance && (f.balance ?? "auto") !== "auto") ); @@ -791,6 +801,51 @@ export function concurrencyOf(f: UpstreamForm): number | null | undefined { return n >= 1 && n <= MAX_CONCURRENCY ? n : undefined; } +/** 最低生成速度的范围(token/秒),和 core 的 `min_speed.tokens_per_sec` 一样 */ +export const MAX_MIN_SPEED = 1000; +/** 判定时长的范围与缺省(秒),和 core 的 `min_speed.window_secs` 一样 */ +export const MIN_SPEED_WINDOW = { min: 5, max: 120 } as const; +export const MIN_SPEED_WINDOW_DEFAULT = 20; + +/** 配置里的 `min_speed` 回填成两格:没有是速度空着、判定时长 20 */ +function minSpeedFields(m: { tokens_per_sec: number; window_secs: number } | null | undefined) { + return { + minSpeed: m ? String(m.tokens_per_sec) : "", + minSpeedWindow: String(m?.window_secs ?? MIN_SPEED_WINDOW_DEFAULT), + }; +} + +function wholeIn(v: string, min: number, max: number): number | undefined { + if (!/^\d+$/.test(v)) return undefined; + const n = Number(v); + return n >= min && n <= max ? n : undefined; +} + +/** 速度那一格写的不是 1 到 1000 的整数 */ +export function minSpeedBad(f: Pick): boolean { + const v = f.minSpeed.trim(); + return v !== "" && wholeIn(v, 1, MAX_MIN_SPEED) === undefined; +} + +/** 判定时长那一格写的不是 5 到 120 的整数。**速度空着时不算**:那时这一项不交 */ +export function minSpeedWindowBad(f: Pick): boolean { + if (f.minSpeed.trim() === "") return false; + return wholeIn(f.minSpeedWindow.trim(), MIN_SPEED_WINDOW.min, MIN_SPEED_WINDOW.max) === undefined; +} + +/** + * 两格合成的 `min_speed`:速度空着是不中断(`null`),任一格写错是 `undefined`(保存按不下去, + * 见 `connectionMissing`) + */ +export function minSpeedOf( + f: Pick, +): { tokens_per_sec: number; window_secs: number } | null | undefined { + if (f.minSpeed.trim() === "") return null; + const tokens = wholeIn(f.minSpeed.trim(), 1, MAX_MIN_SPEED); + const secs = wholeIn(f.minSpeedWindow.trim(), MIN_SPEED_WINDOW.min, MIN_SPEED_WINDOW.max); + return tokens === undefined || secs === undefined ? undefined : { tokens_per_sec: tokens, window_secs: secs }; +} + /** * 用量上限缺什么:有一条填得不对(`limitProblems`)就说一句,毛病写在那一行下面。只有空着 * 没填的说「填写」,别的说「有误」。`rowDays`:请求记录留几天,不知道是 null @@ -852,6 +907,8 @@ export function connectionMissing( if (r.value.trim() === "") return t.headerValue(header); } if (concurrencyOf(f) === undefined) return t.concurrency; + if (minSpeedBad(f)) return t.minSpeed; + if (minSpeedWindowBad(f)) return t.minSpeedWindow; return null; }