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-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() { // 「原本没有这个文件」和「原本是空文件」不一样:还原时前者要把 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-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-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-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; 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 }