Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 49 additions & 2 deletions src-tauri/src/import_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/// 链接的前缀。主机名那一段不分大小写
Expand All @@ -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<u32> = 5..=120;
pub const MIN_SPEED_WINDOW_DEFAULT: u32 = 20;
/// 用量上限最多几条
pub const MAX_LIMITS: usize = 16;
/// 请求数、token 数上限的上限(十五位数)
Expand Down Expand Up @@ -95,6 +100,8 @@ pub enum Param {
Headers,
ClientIdentity,
Concurrency,
MinSpeed,
MinSpeedWindow,
Limits,
Balance,
Models,
Expand Down Expand Up @@ -125,6 +132,8 @@ impl Param {
Self::Headers,
Self::ClientIdentity,
Self::Concurrency,
Self::MinSpeed,
Self::MinSpeedWindow,
Self::Limits,
Self::Balance,
Self::Models,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -928,6 +952,7 @@ fn build(
headers,
client_identity,
concurrency,
min_speed,
limits,
balance,
models,
Expand Down Expand Up @@ -1232,6 +1257,21 @@ fn check_concurrency(c: &str) -> Result<u32, Rejected> {
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<ImportMinSpeed, Rejected> {
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<u32> {
match per {
Expand Down Expand Up @@ -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<String> = p.limits.iter().map(limit_text).collect();
put(Param::Limits, s.join(";"));
Expand Down
74 changes: 74 additions & 0 deletions src-tauri/src/import_link/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down Expand Up @@ -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"),
] {
Expand All @@ -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,
};
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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?"),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/notices/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -961,6 +971,8 @@ pub struct ImportProposal {
pub client_identity: bool,
/// 并发上限,1 到 1000
pub concurrency: Option<u32>,
/// 生成过慢时中断。null = 不中断
pub min_speed: Option<ImportMinSpeed>,
/// 用量上限,按链接里的顺序
pub limits: Vec<ImportLimit>,
/// 余额从哪里读(只有「自定义」)
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/tests/control_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,27 @@ describe("从事件缝出一行", () => {
expect(r?.receivedBytes).toBe(480);
});

/** 生成太慢被切断的:失败事件带着判定时的速度,速度一格照常有数 */
it("生成太慢的失败带上速度", () => {
const rows = new Map<number, RequestRow>();
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<number, RequestRow>();
Expand Down
Loading
Loading