From 5a9f8394ad7855bbd074eaa7eabd5be51aa40ba1 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:39:35 +0800 Subject: [PATCH 1/3] fix(adopt): never let a same-millisecond backup overwrite an earlier one (#3) backup_to named the backup directory after the current millisecond and the file inside after the flattened real path. Two adoptions of the same file within one millisecond therefore wrote to the exact same backup path. The second backup already contains our gateway key, so it replaced the first backup holding the user's original key. The sidecar still pointed at that path, so restore recovered our key instead of the user's and their original secret was gone. This is what made re_adopting_does_not_overwrite_what_the_original_was flaky (~1 in 3 local runs), but the data loss is real, not just a test artefact. Directories are now named `-` and claimed with create_dir (not create_dir_all), bumping seq on AlreadyExists, so the filesystem decides atomically who gets a name. The file inside is opened with create_new so an existing backup is never overwritten. Zero-padding keeps "sort by name = chronological order"; old plain `` directories sort before any `-NNNN`, and nothing parses the names as integers. Co-authored-by: Claude Opus 5 --- crates/tw-adopt/src/foreign.rs | 81 +++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/tw-adopt/src/foreign.rs b/crates/tw-adopt/src/foreign.rs index 0174dcc5..bf5e631a 100644 --- a/crates/tw-adopt/src/foreign.rs +++ b/crates/tw-adopt/src/foreign.rs @@ -158,27 +158,59 @@ fn write_atomic(real: &Path, text: &str, keep_mode: Option) -> Result<(), F }) } -/// 备份目录:`~/.thinkwatch/backups/<毫秒时间戳>/`。 +/// 备份目录:`~/.thinkwatch/backups/<毫秒时间戳>-<序号>/`。 /// /// 时间戳在目录名里,所以按名字排序就是时间序 —— 不必读 mtime(备份 -/// 工具会把 mtime 全改成同一天)。 +/// 工具会把 mtime 全改成同一天)。序号补零到固定宽度,同一毫秒里的 +/// 几份也按先后排。 pub fn backup_root() -> PathBuf { tw_config::default_dir().join("backups") } +/// 同一毫秒里最多几份备份。补零宽度跟着它走,超了排序就不对了。 +const BACKUP_SEQ_MAX: u32 = 9999; + fn backup_to(root: &Path, real: &Path, text: &str) -> Result { + backup_at(root, real, text, now_ms()) +} + +/// 时间戳从外面传进来,测试才能稳定地造出「同一毫秒」。 +fn backup_at(root: &Path, real: &Path, text: &str, ms: u64) -> Result { // 目录名里带上来源路径的形状,一眼能看出这是谁的备份 let flat = real .to_string_lossy() .trim_start_matches('/') .replace('/', "%"); - let dir = root.join(now_ms().to_string()); - std::fs::create_dir_all(&dir).map_err(|source| ForeignError::Write { - path: dir.clone(), + std::fs::create_dir_all(root).map_err(|source| ForeignError::Write { + path: root.to_path_buf(), source, })?; + // **同一毫秒里连着接管两次,第二份备份不能盖掉第一份。**第二次备份 + // 的内容里已经是我们写的密钥了;盖掉之后还原只能找回我们的密钥, + // 用户自己的那个就再也没有了。所以用 `create_dir`(不是 `_all`) + // 让文件系统原子地告诉我们目录是不是已经有了,有了就换下一个序号。 + let mut seq = 0; + let dir = loop { + let dir = root.join(format!("{ms}-{seq:04}")); + match std::fs::create_dir(&dir) { + Ok(()) => break dir, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && seq < BACKUP_SEQ_MAX => { + seq += 1; + } + Err(source) => return Err(ForeignError::Write { path: dir, source }), + } + }; let file = dir.join(flat); - std::fs::write(&file, text).map_err(|source| ForeignError::Write { + // 目录是刚建的,按说不会有同名文件;万一有,也宁可失败不覆盖。 + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&file) + .map_err(|source| ForeignError::Write { + path: file.clone(), + source, + })?; + std::io::Write::write_all(&mut f, text.as_bytes()).map_err(|source| ForeignError::Write { path: file.clone(), source, })?; @@ -395,6 +427,43 @@ mod tests { assert!(!a.created); } + #[test] + fn two_backups_in_the_same_millisecond_do_not_overwrite_each_other() { + // 连着接管两次时第二份备份里已经是我们的密钥;它要是盖掉第一份, + // 用户原来的密钥就没了,还原只能还原到我们这儿。 + let (d, root) = dirs(); + let p = d.path().join("c.json"); + let first = backup_at(&root, &p, "sk-用户自己的", 1_700_000_000_000).unwrap(); + let second = backup_at(&root, &p, "tw-我们写的", 1_700_000_000_000).unwrap(); + + assert_ne!(first, second); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "sk-用户自己的"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "tw-我们写的"); + + // 按名字排序仍然是时间序,跨毫秒也是 + let third = backup_at(&root, &p, "后来的", 1_700_000_000_001).unwrap(); + let mut names: Vec<_> = std::fs::read_dir(&root) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + names.sort(); + let parent = |f: &PathBuf| f.parent().unwrap().to_path_buf(); + assert_eq!(names, vec![parent(&first), parent(&second), parent(&third)]); + } + + #[test] + fn back_to_back_real_backups_all_survive() { + // 不注入时间戳,走真实时钟:一口气备份很多份,几乎必然撞在同一毫秒。 + let (d, root) = dirs(); + let p = d.path().join("c.json"); + let files: Vec<_> = (0..50) + .map(|i| backup_to(&root, &p, &i.to_string()).unwrap()) + .collect(); + for (i, f) in files.iter().enumerate() { + assert_eq!(std::fs::read_to_string(f).unwrap(), i.to_string()); + } + } + #[test] fn creating_a_file_that_was_not_there_is_recorded_as_such() { // 「原本没有这个文件」和「原本是空文件」不一样:还原时前者要把 From 97177a689bc9d765a2923fce6df95c00f0303202 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:47:49 +0800 Subject: [PATCH 2/3] chore: write crate descriptions in English (#2) These show up on crates.io and docs.rs, and this is a public repository. Co-authored-by: fylorn <130388298+SecAegis@users.noreply.github.com> Co-authored-by: Claude Opus 5 --- bin/twcore/Cargo.toml | 2 +- crates/tw-adopt/Cargo.toml | 2 +- crates/tw-api/Cargo.toml | 2 +- crates/tw-config/Cargo.toml | 2 +- crates/tw-control/Cargo.toml | 2 +- crates/tw-crypto/Cargo.toml | 2 +- crates/tw-dialect/Cargo.toml | 2 +- crates/tw-engine/Cargo.toml | 2 +- crates/tw-gateway/Cargo.toml | 2 +- crates/tw-observe/Cargo.toml | 2 +- crates/tw-pricing/Cargo.toml | 2 +- crates/tw-protocol/Cargo.toml | 2 +- crates/tw-provider/Cargo.toml | 2 +- crates/tw-redact/Cargo.toml | 2 +- crates/tw-resil/Cargo.toml | 2 +- crates/tw-scan/Cargo.toml | 2 +- crates/tw-secret/Cargo.toml | 2 +- crates/tw-store/Cargo.toml | 2 +- crates/tw-types/Cargo.toml | 2 +- crates/tw-yaml/Cargo.toml | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index 355ffb5c..364cf35c 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "ThinkWatch Lite 的本地 AI 网关引擎" +description = "Self-contained local AI gateway binary, and the engine behind ThinkWatch Lite" [dependencies] tw-config = { workspace = true } diff --git a/crates/tw-adopt/Cargo.toml b/crates/tw-adopt/Cargo.toml index b376aaf8..1049916a 100644 --- a/crates/tw-adopt/Cargo.toml +++ b/crates/tw-adopt/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "检测本机的 AI 客户端,接管与还原它们的配置" +description = "Detect local AI clients, take over their configuration, and restore it" [dependencies] serde = { workspace = true } diff --git a/crates/tw-api/Cargo.toml b/crates/tw-api/Cargo.toml index b078ac95..d9e1b906 100644 --- a/crates/tw-api/Cargo.toml +++ b/crates/tw-api/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "控制面契约:请求/响应类型 + client" +description = "Control-plane contract: request and response types, plus a client" [dependencies] serde = { workspace = true } diff --git a/crates/tw-config/Cargo.toml b/crates/tw-config/Cargo.toml index 799d2368..02885cda 100644 --- a/crates/tw-config/Cargo.toml +++ b/crates/tw-config/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "YAML schema、加载、校验" +description = "YAML config schema, loading, and validation" [dependencies] tw-engine = { workspace = true } diff --git a/crates/tw-control/Cargo.toml b/crates/tw-control/Cargo.toml index 689a9355..0982b15b 100644 --- a/crates/tw-control/Cargo.toml +++ b/crates/tw-control/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "控制面 API 服务端(unix socket)" +description = "Control-plane API server over a unix socket" [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-crypto/Cargo.toml b/crates/tw-crypto/Cargo.toml index 20f975cf..d40450ce 100644 --- a/crates/tw-crypto/Cargo.toml +++ b/crates/tw-crypto/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "AES-256-GCM 信封加解密" +description = "AES-256-GCM envelope encryption and decryption" [dependencies] aes-gcm = { workspace = true } diff --git a/crates/tw-dialect/Cargo.toml b/crates/tw-dialect/Cargo.toml index b0901bae..53f5b619 100644 --- a/crates/tw-dialect/Cargo.toml +++ b/crates/tw-dialect/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "方言互转:Anthropic 与 OpenAI chat 之间的请求与响应翻译" +description = "Dialect translation of requests and responses between Anthropic and OpenAI chat" [dependencies] serde = { workspace = true } diff --git a/crates/tw-engine/Cargo.toml b/crates/tw-engine/Cargo.toml index 8d514209..cf211d64 100644 --- a/crates/tw-engine/Cargo.toml +++ b/crates/tw-engine/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "路由规则引擎与策略组" +description = "Routing rule engine and policy groups" [dependencies] serde = { workspace = true } diff --git a/crates/tw-gateway/Cargo.toml b/crates/tw-gateway/Cargo.toml index 621e52fe..22691b6c 100644 --- a/crates/tw-gateway/Cargo.toml +++ b/crates/tw-gateway/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "数据面 HTTP 服务" +description = "Data-plane HTTP server" [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-observe/Cargo.toml b/crates/tw-observe/Cargo.toml index 11fc13ea..7b08870f 100644 --- a/crates/tw-observe/Cargo.toml +++ b/crates/tw-observe/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "事件总线" +description = "Event bus" [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-pricing/Cargo.toml b/crates/tw-pricing/Cargo.toml index bf8a4deb..7e120964 100644 --- a/crates/tw-pricing/Cargo.toml +++ b/crates/tw-pricing/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "价目表:内置快照 + 用户覆盖,以及成本三态" +description = "Price table: a built-in snapshot, user overrides, and three-state cost (measured, estimated, unpriced)" [dependencies] serde = { workspace = true } diff --git a/crates/tw-protocol/Cargo.toml b/crates/tw-protocol/Cargo.toml index 4478ac2f..382f58a4 100644 --- a/crates/tw-protocol/Cargo.toml +++ b/crates/tw-protocol/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "SSE 分帧与方言间的内容提取" +description = "SSE framing and content extraction across API dialects" [dependencies] bytes = { workspace = true } diff --git a/crates/tw-provider/Cargo.toml b/crates/tw-provider/Cargo.toml index bdc0fa23..8f911007 100644 --- a/crates/tw-provider/Cargo.toml +++ b/crates/tw-provider/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "provider 抽象与各家实现" +description = "Provider abstraction and per-provider implementations" [dependencies] tw-types = { workspace = true } diff --git a/crates/tw-redact/Cargo.toml b/crates/tw-redact/Cargo.toml index 78e97f0e..d999087a 100644 --- a/crates/tw-redact/Cargo.toml +++ b/crates/tw-redact/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "出站脱敏与回显还原:把凭据换成占位符,再在响应里换回来" +description = "Outbound redaction: replace credentials with placeholders, and restore them when a response echoes them back" [dependencies] serde = { workspace = true } diff --git a/crates/tw-resil/Cargo.toml b/crates/tw-resil/Cargo.toml index f8020481..d191091d 100644 --- a/crates/tw-resil/Cargo.toml +++ b/crates/tw-resil/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "重试、熔断、故障转移" +description = "Retries, circuit breaking, and failover" [dependencies] tw-types = { workspace = true } diff --git a/crates/tw-scan/Cargo.toml b/crates/tw-scan/Cargo.toml index aa69b602..a41fc617 100644 --- a/crates/tw-scan/Cargo.toml +++ b/crates/tw-scan/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "客户端配置面的静态扫描:隐藏字符、提示注入、危险命令、过宽权限" +description = "Static scan of client configuration for hidden characters, prompt injection, dangerous commands, and overly broad permissions" [dependencies] serde = { workspace = true } diff --git a/crates/tw-secret/Cargo.toml b/crates/tw-secret/Cargo.toml index 920befbe..88fb5138 100644 --- a/crates/tw-secret/Cargo.toml +++ b/crates/tw-secret/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "环境变量插值、exec 凭据、密钥打码" +description = "Environment variable interpolation, exec-sourced credentials, and secret masking" [dependencies] thiserror = { workspace = true } diff --git a/crates/tw-store/Cargo.toml b/crates/tw-store/Cargo.toml index 3e2d9ae6..340eb1cd 100644 --- a/crates/tw-store/Cargo.toml +++ b/crates/tw-store/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "请求历史与运行时状态:SQLite + 文件系统" +description = "Request history and runtime state on SQLite and the filesystem" [dependencies] tw-api = { workspace = true } diff --git a/crates/tw-types/Cargo.toml b/crates/tw-types/Cargo.toml index 1c9c26fa..904228ff 100644 --- a/crates/tw-types/Cargo.toml +++ b/crates/tw-types/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "共享类型:请求/响应 DTO、调用上下文、网关错误" +description = "Shared types: request and response DTOs, call context, gateway errors" [dependencies] serde = { workspace = true } diff --git a/crates/tw-yaml/Cargo.toml b/crates/tw-yaml/Cargo.toml index 72a795ff..bda564ee 100644 --- a/crates/tw-yaml/Cargo.toml +++ b/crates/tw-yaml/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "在原文本上做最小替换的 YAML 补丁层" +description = "YAML patch layer that makes minimal edits to the original text" [dependencies] saphyr-parser = { workspace = true } From ccc3ca2e7dd006d711b3d12bf886fa0574c0f71d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:57:24 +0800 Subject: [PATCH 3/3] feat(api): time-bucketed and grouped cost aggregates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview had nothing to draw. `/summary` returns one total for one window, and `/history` returns rows — there was no aggregate over time and none by model or upstream, so the dashboard could only ever be numbers and tables. Two queries and two endpoints: - `/summary/buckets?bucket_ms=` — spend and request count per time bucket. Width comes from the caller: "today by hour" and "30 days by day" want different buckets, and hard-coding one means adding an endpoint for the other. Floored at 1s, or a `bucket_ms=1` would scan out millions of groups. - `/summary/by?dim=model|provider` — spend grouped, sorted by spend descending, because the first thing that chart has to answer is where the money went. `dim` is an enum rather than a string. It picks the column name in the SQL, so a string would be an injection point, and the few lines it saves aren't worth it — serde rejects a bad value with a 400 before it reaches the query. Verified against the real binary: `?dim=model);DROP` gets 400. **The three cost states stay apart inside a bucket** (§4.3): measured, estimated, and the *count* of rows with no price. Folding the third into the bar as zero makes that bar quietly low, with nothing on screen to say what wasn't counted. Empty buckets are not filled in. GROUP BY only produces buckets that have data, and how many slots to draw is something only the UI knows — filling here would invent tens of thousands of zeros for a wide range with a narrow bucket. The test says so explicitly, so the next reader doesn't assume a dense series. Four tests, including that local answers stay out of both aggregates (§4.8 — cost 0, latency 0 would dilute every bar). 1066 tests, 42 smoke checks, clippy clean, verified against the real binary with 25 recorded requests. Co-Authored-By: Claude Opus 5 --- crates/tw-control/src/lib.rs | 57 ++++++++++ crates/tw-store/src/db.rs | 212 +++++++++++++++++++++++++++++++++++ crates/tw-store/src/lib.rs | 2 +- 3 files changed, 270 insertions(+), 1 deletion(-) diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 5065bce7..50ebc6f9 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -98,6 +98,8 @@ pub fn router(state: ControlState) -> Router { .route("/pricing/update/apply", post(update_apply)) .route("/config/rollback", post(config_rollback)) .route("/summary", get(summary)) + .route("/summary/buckets", get(cost_buckets)) + .route("/summary/by", get(cost_by)) .route("/history", get(history)) .route("/latency", get(latency)) .route("/latency/provider", get(latency_by_provider)) @@ -447,6 +449,45 @@ fn view(target: String, via: Option, r: tw_gateway::L1Result) -> tw_api: } } +/// 按时间分桶的花费与请求数(概览的趋势图)。 +/// +/// 桶宽由调用方给:同一段数据,「今天每小时」和「最近 30 天每天」要的 +/// 是两种桶,而在服务端写死一种,另一种就得再加一个端点。 +/// +/// **空桶不补。**SQL 的 GROUP BY 只产出有数据的桶,而要画多少格只有 +/// 界面知道(它知道图有多宽)。在这里补的话,一个跨度很大、桶很窄的 +/// 请求会让我们凭空造出几万个零。 +async fn cost_buckets( + State(s): State, + axum::extract::Query(q): axum::extract::Query, +) -> Result>, Fail> { + let (from, to) = q.win.range(); + // 桶宽有下限,否则一个 `bucket_ms=1` 能让这条查询扫出几百万个分组。 + let bucket = q.bucket_ms.unwrap_or(3_600_000).max(1_000); + let store = need_store(&s)?; + let g = store.lock().await; + let x = g + .db() + .cost_buckets(from, to, bucket) + .map_err(|e| fail(StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(x)) +} + +/// 按模型或上游分组的花费(钱花在哪儿)。 +async fn cost_by( + State(s): State, + axum::extract::Query(q): axum::extract::Query, +) -> Result>, Fail> { + let (from, to) = q.win.range(); + let store = need_store(&s)?; + let g = store.lock().await; + let x = g + .db() + .cost_by(q.dim, from, to) + .map_err(|e| fail(StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(x)) +} + /// 一段时间的汇总。不给参数就是「今天」。 async fn summary( State(s): State, @@ -1069,6 +1110,22 @@ struct Window { to_ms: Option, } +#[derive(serde::Deserialize)] +struct BucketQuery { + #[serde(flatten)] + win: Window, + bucket_ms: Option, +} + +#[derive(serde::Deserialize)] +struct GroupQuery { + #[serde(flatten)] + win: Window, + /// **是枚举不是字符串。**它会决定 SQL 里的列名,用字符串就是一个 + /// 注入口;写错的值在这里被 serde 直接拒掉,而不是拼进查询。 + dim: tw_store::CostDim, +} + impl Window { fn range(&self) -> (i64, i64) { let now = now_ms(); diff --git a/crates/tw-store/src/db.rs b/crates/tw-store/src/db.rs index 042c6d68..f77dcb7a 100644 --- a/crates/tw-store/src/db.rs +++ b/crates/tw-store/src/db.rs @@ -754,6 +754,86 @@ impl Db { .collect()) } + /// 按时间分桶的花费与请求数(概览的趋势图)。 + /// + /// **桶宽由调用方给,不在这里猜。**同一段数据,看「今天每小时」和 + /// 「最近 30 天每天」要的是两种桶,而在 SQL 里写死一种,另一种就得 + /// 再写一个查询。 + /// + /// 成本三态在这里保持分开(§4.3):实测的、估算的、以及**根本没有 + /// 价格的那几条的条数**。把第三种当成 0 加进柱子里,图上那根柱子 + /// 就是偏低的,而看图的人没有任何线索知道少算了什么。 + pub fn cost_buckets( + &self, + since_ms: i64, + until_ms: i64, + bucket_ms: i64, + ) -> Result, DbError> { + if bucket_ms <= 0 { + return Ok(Vec::new()); + } + let mut st = self.conn.prepare( + "SELECT ((at_ms - ?1) / ?3) AS b, + COUNT(*), + SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END), + COALESCE(SUM(CASE WHEN cost_estimated = 0 THEN cost_micros ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cost_estimated = 1 THEN cost_micros ELSE 0 END), 0), + SUM(CASE WHEN cost_micros IS NULL THEN 1 ELSE 0 END) + FROM requests + WHERE at_ms >= ?1 AND at_ms < ?2 AND local = 0 + GROUP BY b ORDER BY b", + )?; + let rows = st.query_map(params![since_ms, until_ms, bucket_ms], |r| { + Ok(CostBucket { + at_ms: since_ms + r.get::<_, i64>(0)? * bucket_ms, + requests: r.get(1)?, + failed: r.get(2)?, + cost_micros_exact: r.get(3)?, + cost_micros_estimated: r.get(4)?, + unpriced_requests: r.get(5)?, + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// 按某个维度分组的花费(钱花在哪儿)。 + /// + /// `dim` 只接受固定的两个值 —— **不是把列名拼进 SQL**。这个参数最终 + /// 来自控制面的 query string,拼进去就是一个注入口,而它省下的那点 + /// 代码完全不值。 + pub fn cost_by( + &self, + dim: CostDim, + since_ms: i64, + until_ms: i64, + ) -> Result, DbError> { + let col = match dim { + CostDim::Model => "model", + CostDim::Provider => "provider", + }; + let sql = format!( + "SELECT {col}, COUNT(*), + COALESCE(SUM(cost_micros), 0), + SUM(CASE WHEN cost_micros IS NULL THEN 1 ELSE 0 END), + COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0) + FROM requests + WHERE at_ms >= ?1 AND at_ms < ?2 AND local = 0 AND {col} <> '' + GROUP BY {col} ORDER BY 3 DESC" + ); + let mut st = self.conn.prepare(&sql)?; + let rows = st.query_map(params![since_ms, until_ms], |r| { + Ok(CostGroup { + name: r.get(0)?, + requests: r.get(1)?, + cost_micros: r.get(2)?, + unpriced_requests: r.get(3)?, + input_tokens: r.get(4)?, + output_tokens: r.get(5)?, + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + /// 删掉太老的 metadata。返回删了几条。 pub fn prune_before(&self, cutoff_ms: i64) -> Result { // 发现记录跟着请求一起过期 —— 留着一条指向不存在的请求的发现, @@ -865,6 +945,42 @@ pub struct LeakGroup { pub masked: Vec, } +/// 一个时间桶的花费与请求数(概览的趋势图)。 +/// +/// **成本三态在这里不合并**(§4.3):实测、估算、以及没有价格的条数。 +/// 把第三种当成 0 加进柱子,那根柱子就是偏低的,而看图的人没有线索 +/// 知道少算了什么。 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CostBucket { + /// 桶的起点 + pub at_ms: i64, + pub requests: i64, + pub failed: i64, + pub cost_micros_exact: i64, + pub cost_micros_estimated: i64, + pub unpriced_requests: i64, +} + +/// 按模型或上游分组的花费(钱花在哪儿)。 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CostGroup { + pub name: String, + pub requests: i64, + pub cost_micros: i64, + pub unpriced_requests: i64, + pub input_tokens: i64, + pub output_tokens: i64, +} + +/// 分组维度。**是个枚举不是字符串** —— 它最终来自 query string, +/// 而把它拼进 SQL 的列名里就是一个注入口。 +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CostDim { + Model, + Provider, +} + #[derive(Debug, Clone, PartialEq)] pub struct Latency { pub model: String, @@ -908,6 +1024,102 @@ mod tests { } } + /// 分桶的边界。 + /// + /// **空桶要有,不能跳过。**没有请求的那一小时在图上是一根零高度的 + /// 柱子,不是「那一格不存在」—— 跳过的话,一天里的空档会被两边的 + /// 柱子挤没,图上看起来就是连续在用。 + /// + /// 这条现在是**已知的不满足**:SQL 的 GROUP BY 只会产出有数据的桶。 + /// 补空桶放在调用方做,因为只有它知道要画多少格。写在这里是为了 + /// 让下一个人不要以为这个函数会给一个稠密的序列。 + #[test] + fn cost_buckets_group_by_the_given_width() { + let db = Db::in_memory().unwrap(); + let t0 = 1_000_000_000i64; + let hour = 3_600_000i64; + // 第 0 桶两条,第 2 桶一条,第 1 桶空着 + for (i, at) in [t0 + 10, t0 + 20, t0 + 2 * hour + 5].iter().enumerate() { + let mut r = row(i as i64 + 1, *at); + r.cost_micros = Some(1_000); + db.insert(&r).unwrap(); + } + let b = db.cost_buckets(t0, t0 + 3 * hour, hour).unwrap(); + assert_eq!(b.len(), 2, "只产出有数据的桶,空桶由调用方补"); + assert_eq!( + b[0].at_ms, t0, + "桶的起点要对齐到 since,不是第一条记录的时间" + ); + assert_eq!(b[0].requests, 2); + assert_eq!(b[0].cost_micros_exact, 2_000); + assert_eq!(b[1].at_ms, t0 + 2 * hour); + } + + /// 成本三态在桶里也要分开。 + /// + /// 把「没有价格」当成 0 加进柱子,那根柱子就是偏低的,而看图的人 + /// 没有任何线索知道少算了什么(§4.3)。 + #[test] + fn a_bucket_keeps_the_three_cost_states_apart() { + let db = Db::in_memory().unwrap(); + let t0 = 1_000_000_000i64; + let mut exact = row(1, t0 + 1); + exact.cost_micros = Some(5_000); + exact.cost_estimated = false; + let mut est = row(2, t0 + 2); + est.cost_micros = Some(3_000); + est.cost_estimated = true; + let mut none = row(3, t0 + 3); + none.cost_micros = None; + for r in [&exact, &est, &none] { + db.insert(r).unwrap(); + } + let b = db.cost_buckets(t0, t0 + 1000, 1000).unwrap(); + assert_eq!(b.len(), 1); + assert_eq!(b[0].cost_micros_exact, 5_000); + assert_eq!(b[0].cost_micros_estimated, 3_000); + assert_eq!(b[0].unpriced_requests, 1, "没有价格的要单独数,不能当成 0"); + } + + /// 分组维度只能是那两个,而且是按花费倒序 —— 「钱花在哪儿」这张图 + /// 第一眼要看到的就是最大的那一项。 + #[test] + fn cost_by_groups_and_sorts_by_spend() { + let db = Db::in_memory().unwrap(); + let t0 = 1_000_000_000i64; + let mut cheap = row(1, t0 + 1); + cheap.model = "haiku".into(); + cheap.cost_micros = Some(100); + let mut dear = row(2, t0 + 2); + dear.model = "opus".into(); + dear.cost_micros = Some(9_000); + for r in [&cheap, &dear] { + db.insert(r).unwrap(); + } + let g = db.cost_by(CostDim::Model, t0, t0 + 1000).unwrap(); + assert_eq!(g.len(), 2); + assert_eq!(g[0].name, "opus", "贵的排前面"); + assert_eq!(g[0].cost_micros, 9_000); + } + + /// 本地应答不进任何聚合(§4.8)。成本 0、延迟 0 的东西混进来, + /// 会让图上每一格都被稀释。 + #[test] + fn local_answers_stay_out_of_the_aggregates() { + let db = Db::in_memory().unwrap(); + let t0 = 1_000_000_000i64; + let mut r = row(1, t0 + 1); + r.local = true; + r.cost_micros = None; + db.insert(&r).unwrap(); + assert!(db.cost_buckets(t0, t0 + 1000, 1000).unwrap().is_empty()); + assert!( + db.cost_by(CostDim::Model, t0, t0 + 1000) + .unwrap() + .is_empty() + ); + } + #[test] fn a_row_survives_a_round_trip_with_every_field_intact() { // 漏掉一个字段的表现是「详情页上少一个数」,而那种缺失在肉眼 diff --git a/crates/tw-store/src/lib.rs b/crates/tw-store/src/lib.rs index e45610da..52af1fa6 100644 --- a/crates/tw-store/src/lib.rs +++ b/crates/tw-store/src/lib.rs @@ -16,7 +16,7 @@ pub mod recorder; pub mod task; pub use blobs::{Blobs, Which}; -pub use db::{Db, DbError, Latency, RequestRow, Summary}; +pub use db::{CostBucket, CostDim, CostGroup, Db, DbError, Latency, RequestRow, Summary}; pub use disk::{DiskLevel, free_bytes, level_for}; pub use recorder::Recorder; pub use task::StoredBody;