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
3 changes: 3 additions & 0 deletions crates/tw-api/src/ep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ endpoints! {
CostBuckets: GET "/summary/buckets", api::BucketQuery => Vec<api::CostBucket>;
CostBucketsBy: GET "/summary/buckets/by", api::BucketGroupQuery => Vec<api::CostBucketGroup>;
CostBy: GET "/summary/by", api::GroupQuery => Vec<api::CostGroup>;
/// 很长一段时间里的用量,按格合计(概览上的「最近一年」)。按整点另存,不随请求记录的
/// 保留期删除、不随请求库重建清空。参数同 `/summary/buckets`
UsageHistory: GET "/summary/history", api::BucketQuery => api::UsageHistory;
/// 各条路由走了多少请求、各条规则命中了多少,记录从哪一刻起是全的
RouteStats: GET "/summary/routes", api::Window => api::RouteStats;
History: GET "/history", api::ListQuery => Vec<api::HistoryRow>;
Expand Down
33 changes: 32 additions & 1 deletion crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,11 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// `remembered`):上游因为推理强度回 400 时,同一家改一档(或者去掉强度)再发一次,是尝试链上
/// 新的一跳,不算退避重试;记下这一家的这个模型不认那一档,7 天之内直接改。照 53 写的界面
/// 保存规则时带的 `thinking` 不起作用
pub const CONTROL_API_VERSION: u32 = 54;
///
/// **55 起有用量历史**:`GET /summary/history`([`BucketQuery`] → [`UsageHistory`]),很长一段时间
/// 里的用量按格合计,概览上「最近一年」用。按整点另存在数据目录的 `usage.db` 里,请求库重建、
/// 请求记录过了保留期都不丢
pub const CONTROL_API_VERSION: u32 = 55;

/// `/live` 的速度看最近多久(见 [`LiveView::tokens_per_sec`]),毫秒
pub const LIVE_RATE_WINDOW_MS: u64 = 3_000;
Expand Down Expand Up @@ -4421,6 +4425,33 @@ pub struct CostBucket {
pub ttft_samples: i64,
}

/// 很长一段时间里的用量,按格合计(`GET /summary/history`)。概览上「最近一年」那张热力图用。
///
/// **不从请求记录里数**:请求记录只留保留期那么多天,请求库换版本时还会整个重建。用量另按
/// UTC 整点累加存着,这两样都不丢;口径和 [`Summary`] 一样(本地应答不算,费用三态分开)。
/// 一个请求在它结束时加进它开始的那个整点,所以还在跑的不在里面。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct UsageHistory {
/// 从什么时候起有记录,Unix 毫秒:这一刻之前的格子是「没有记录」,不是「没有用量」。
/// 开始记用量历史时请求记录里已有的行补进来了,所以是那时最老那一行的时刻;那时一行都没有
/// 就是开始记的那一刻。从没记过是 None。
///
/// **说的是用量历史,不是逐条记录**:它定下之后不再变,请求记录过了保留期被删、请求库重建都
/// 不动它。逐条记录从哪一刻起有看 [`UsageHistory::detail_since_ms`]
pub since_ms: Option<i64>,
/// 逐条记录从哪一刻起有,Unix 毫秒:请求记录里最老那一行开始的时刻(本地应答也算,口径同
/// [`RouteStats::covered_since_ms`])。请求记录是空的是 None。
///
/// **概览、流量页按逐条记录算**:这一刻之前的日子在用量历史里有数,在那些页上却是零 ——
/// 记录过了保留期被删了、请求库重建过。它随保留期往后挪
pub detail_since_ms: Option<i64>,
/// 按给的格宽分的格子,**稀疏**(没有用量的格子不在里面),分格算法同 `/summary/buckets`
/// (格子的起点是 `from + k × bucket`)。累加的单位是整点:起点不在整点上时,一个整点整个
/// 落在它开始的那一格。首 token 的三项不记(None、None、0)
pub buckets: Vec<CostBucket>,
}

/// 一个时间桶里,某一个模型(或上游)的那部分。
///
/// **和 `CostBucket` 是两个查询,不是一个的扩展。**趋势图要回答的是
Expand Down
17 changes: 17 additions & 0 deletions crates/tw-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ pub fn router(state: ControlState) -> Router {
.at(ep::Summary, summary)
.at(ep::CostBuckets, cost_buckets)
.at(ep::CostBucketsBy, cost_buckets_by)
.at(ep::UsageHistory, usage_history)
.at(ep::CostBy, cost_by)
.at(ep::RouteStats, route_stats)
.at(ep::History, history)
Expand Down Expand Up @@ -715,6 +716,22 @@ async fn cost_buckets(
Ok(Json(x))
}

/// 很长一段时间里的用量,按格合计(概览上的「最近一年」,见 [`tw_api::UsageHistory`])。
///
/// 参数、缺省值和桶宽的下限都同 `/summary/buckets`:界面拿同一套补空格的办法画它。
/// **空格同样不补**,而且这里的格子只有有用量的那些小时那么多,跨度再大也不会凭空造出零来。
/// 用量历史这一次用不了(更新的版本建的、打不开)是 500,请求记录照常
async fn usage_history(
State(s): State<ControlState>,
axum::extract::Query(q): axum::extract::Query<tw_api::BucketQuery>,
) -> Result<Json<tw_api::UsageHistory>, Fail> {
let (from, to) = range(q.from_ms, q.to_ms);
let bucket = q.bucket_ms.unwrap_or(3_600_000).max(1_000);
let store = need_store(&s)?;
let x = on_store(store, move |g| g.db().usage_history(from, to, bucket)).await?;
Ok(Json(x.map_err(records)?))
}

/// 按模型或上游分组的花费(钱花在哪儿)。
async fn cost_buckets_by(
State(s): State<ControlState>,
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-control/tests/handover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async fn status_reports_the_handover_phase_and_the_draining_count() {
let st = status(&s).await;
assert_eq!(st.handover, tw_api::HandoverState::Draining);
assert_eq!(st.draining, 3);
assert_eq!(st.api_version, 54);
assert_eq!(st.api_version, 55);
}

/// arm 之后(以及接替者正在接管时)不开始新的账号登录:回调端口不交接。(平常、排空中的
Expand Down
61 changes: 57 additions & 4 deletions crates/tw-control/tests/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,20 @@ fn req(id: i64, model: &str) -> tw_store::db::RequestRow {

fn app(rows: &[tw_store::db::RequestRow]) -> (tempfile::TempDir, axum::Router) {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("config.yaml");
let app = app_in(d.path(), rows);
(d, app)
}

fn app_in(d: &std::path::Path, rows: &[tw_store::db::RequestRow]) -> axum::Router {
let p = d.join("config.yaml");
std::fs::write(&p, BASE).unwrap();
let db = tw_store::Db::open(&d.path().join("data.db")).unwrap();
let db = tw_store::Db::open(&d.join("data.db")).unwrap();
for r in rows {
db.insert(r).unwrap();
}
let rec = tw_store::Recorder::new(
db,
tw_store::Blobs::new(d.path().join("blobs")),
tw_store::Blobs::new(d.join("blobs")),
tw_pricing::shared(tw_pricing::PriceBook::builtin().unwrap()),
);
let cfg: tw_config::Config = serde_yaml_ng::from_str(BASE).unwrap();
Expand All @@ -82,7 +87,7 @@ fn app(rows: &[tw_store::db::RequestRow]) -> (tempfile::TempDir, axum::Router) {
chatgpt: Default::default(),
zai: Default::default(),
};
(d, tw_control::router(state))
tw_control::router(state)
}

async fn get(app: &axum::Router, path: &str) -> serde_json::Value {
Expand Down Expand Up @@ -244,3 +249,51 @@ async fn traffic_and_egress_reach_every_view() {
serde_json::json!([{"model": "我", "p50": 300, "p95": 500, "samples": 2}])
);
}

/// 用量历史:参数同 `/summary/buckets`,格子带着「从什么时候起有记录」。
///
/// 请求记录先有、用量历史后建(升级的用户):建的时候把已有的行补进来,起点是最老那一行
#[tokio::test]
async fn the_usage_history_answers_the_bucket_query_from_the_hours_it_kept() {
let d = tempfile::tempdir().unwrap();
{
let db = tw_store::Db::open(&d.path().join("data.db")).unwrap();
db.insert(&req(1, "claude-sonnet-4-5")).unwrap();
let mut unknown = req(2, "中转站自己起的名字");
unknown.cost_micros = None;
db.insert(&unknown).unwrap();
}
for f in ["usage.db", "usage.db-wal", "usage.db-shm"] {
let _ = std::fs::remove_file(d.path().join(f));
}
let app = app_in(d.path(), &[]);
let h = get(
&app,
"/summary/history?from_ms=0&to_ms=86400000&bucket_ms=86400000",
)
.await;
assert_eq!(h["since_ms"], 1001, "{h}");
assert_eq!(h["detail_since_ms"], 1001, "{h}");
let b = &h["buckets"];
assert_eq!(b.as_array().unwrap().len(), 1, "{h}");
assert_eq!(b[0]["at_ms"], 0, "{h}");
assert_eq!(b[0]["requests"], 2, "{h}");
assert_eq!(b[0]["cost_micros_exact"], 1_000, "{h}");
assert_eq!(b[0]["unpriced_requests"], 1, "{h}");
assert_eq!(b[0]["input_tokens"], 100, "{h}");
assert_eq!(b[0]["ttft_samples"], 0, "{h}");
assert!(b[0].get("ttft_p50_ms").is_none(), "{h}");
// 桶宽有下限(同 `/summary/buckets`):一毫秒一格按一秒算
let h = get(
&app,
"/summary/history?from_ms=500&to_ms=86400000&bucket_ms=1",
)
.await;
assert_eq!(
h["buckets"].as_array().unwrap().len(),
0,
"整点 0 在起点之前:{h}"
);
let h = get(&app, "/summary/history?from_ms=0&bucket_ms=1").await;
assert_eq!(h["buckets"][0]["at_ms"], 0, "{h}");
}
Loading
Loading