From 5ab12e04ecee48385b64eb019301968d2d3a4702 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 24 Oct 2026 10:55:00 +0800 Subject: [PATCH] feat(admin): accept the cache-write price in the model editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #433 added `models.cache_write_input_per_m` and made billing charge it, but the admin model editor never wrote the column: a model created or edited at `POST /api/admin/models` (or `PATCH /api/admin/models/:id`) always carried `cache_write_input_per_m = 0`, so an operator billing cache-write tokens had no supported way to set the fifth price through the API. The field now behaves exactly like its sibling prices: present in the create and patch request bodies (default 0, which does not fall back to `input_per_m`), rejected when negative by the same `validate_common` check that guards the other six, written by both the INSERT and the UPDATE, and returned by both readback paths — the single-row SELECT shared by create and patch, and `dao::list_all_models` behind `GET /api/admin/models`. The test creates a model with seven distinct prices so any divergence between a SELECT's column order and its `r.get(i)` indices reddens on the wrong value rather than passing silently. --- src/dao.rs | 12 ++-- src/routes/admin_models.rs | 57 +++++++++++------- src/routes/mod.rs | 118 +++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 25 deletions(-) diff --git a/src/dao.rs b/src/dao.rs index 9e62179..ed22100 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -289,11 +289,12 @@ pub fn list_all_models(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, provider, model, currency, input_per_m, output_per_m, \ context_length, max_output, vision, cache_hit_input_per_m, \ - peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, updated_at \ + cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \ + peak_cache_hit_input_per_m, updated_at \ FROM models ORDER BY provider, model", )?; let rows = stmt.query_map([], |r| { - let updated_at: String = r.get(13)?; + let updated_at: String = r.get(14)?; Ok(serde_json::json!({ "id": r.get::<_, i64>(0)?, "provider": r.get::<_, String>(1)?, @@ -305,9 +306,10 @@ pub fn list_all_models(conn: &Connection) -> Result> { "max_output": r.get::<_, i64>(7)?, "vision": r.get::<_, i64>(8)?, "cache_hit_input_per_m": r.get::<_, f64>(9)?, - "peak_input_per_m": r.get::<_, f64>(10)?, - "peak_output_per_m": r.get::<_, f64>(11)?, - "peak_cache_hit_input_per_m": r.get::<_, f64>(12)?, + "cache_write_input_per_m": r.get::<_, f64>(10)?, + "peak_input_per_m": r.get::<_, f64>(11)?, + "peak_output_per_m": r.get::<_, f64>(12)?, + "peak_cache_hit_input_per_m": r.get::<_, f64>(13)?, "updated_at": utc_iso(&updated_at), })) })?; diff --git a/src/routes/admin_models.rs b/src/routes/admin_models.rs index 861b890..4d3995f 100644 --- a/src/routes/admin_models.rs +++ b/src/routes/admin_models.rs @@ -2,8 +2,8 @@ //! //! - GET /api/admin/models:全部模型列表(含 id / 所有字段,供管理表格) //! - POST /api/admin/models:新增 {provider, model, currency, input_per_m, output_per_m, -//! context_length?, max_output?, vision?, cache_hit_input_per_m?, peak_input_per_m?, -//! peak_output_per_m?, peak_cache_hit_input_per_m?} +//! context_length?, max_output?, vision?, cache_hit_input_per_m?, cache_write_input_per_m?, +//! peak_input_per_m?, peak_output_per_m?, peak_cache_hit_input_per_m?} //! 校验:provider/model 非空、价格 ≥ 0、currency ∈ {USD, CNY}、(provider, model) 唯一冲突 409 //! - PATCH /api/admin/models/:id:部分更新(任意字段) //! - DELETE /api/admin/models/:id:直接删除(只删目录行、**不改写 keys**;删除的后果由路由入口的 @@ -40,6 +40,9 @@ pub struct ModelCreate { pub vision: i64, #[serde(default)] pub cache_hit_input_per_m: f64, + /// 缓存写入(cache-creation)输入价(#433;缺省 0 = 缓存写入不收费) + #[serde(default)] + pub cache_write_input_per_m: f64, /// 高峰时段价(rant 2026-08-20T11:58:40;缺省 0 = 不启用高峰计费) #[serde(default)] pub peak_input_per_m: f64, @@ -65,19 +68,21 @@ pub struct ModelPatch { pub max_output: Option, pub vision: Option, pub cache_hit_input_per_m: Option, + pub cache_write_input_per_m: Option, pub peak_input_per_m: Option, pub peak_output_per_m: Option, pub peak_cache_hit_input_per_m: Option, } /// 校验通用字段:价格 ≥ 0、vision ∈ {0,1}、currency 枚举 -#[allow(clippy::too_many_arguments)] // 价格字段平铺(6 个价格 + currency + vision),保持与请求结构体同序 +#[allow(clippy::too_many_arguments)] // 价格字段平铺(7 个价格 + currency + vision),保持与请求结构体同序 fn validate_common( currency: &str, input_per_m: f64, output_per_m: f64, vision: i64, cache_hit_input_per_m: f64, + cache_write_input_per_m: f64, peak_input_per_m: f64, peak_output_per_m: f64, peak_cache_hit_input_per_m: f64, @@ -91,6 +96,7 @@ fn validate_common( if input_per_m < 0.0 || output_per_m < 0.0 || cache_hit_input_per_m < 0.0 + || cache_write_input_per_m < 0.0 || peak_input_per_m < 0.0 || peak_output_per_m < 0.0 || peak_cache_hit_input_per_m < 0.0 @@ -122,18 +128,19 @@ fn row_to_json(r: &rusqlite::Row) -> rusqlite::Result { "max_output": r.get::<_, i64>(7)?, "vision": r.get::<_, i64>(8)?, "cache_hit_input_per_m": r.get::<_, f64>(9)?, - "peak_input_per_m": r.get::<_, f64>(10)?, - "peak_output_per_m": r.get::<_, f64>(11)?, - "peak_cache_hit_input_per_m": r.get::<_, f64>(12)?, - "updated_at": crate::dao::utc_iso(&r.get::<_, String>(13)?), - "context_window": r.get::<_, i64>(14)?, + "cache_write_input_per_m": r.get::<_, f64>(10)?, + "peak_input_per_m": r.get::<_, f64>(11)?, + "peak_output_per_m": r.get::<_, f64>(12)?, + "peak_cache_hit_input_per_m": r.get::<_, f64>(13)?, + "updated_at": crate::dao::utc_iso(&r.get::<_, String>(14)?), + "context_window": r.get::<_, i64>(15)?, })) } const ROW_SELECT: &str = "SELECT id, provider, model, currency, input_per_m, output_per_m, \ context_length, max_output, vision, cache_hit_input_per_m, \ - peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, \ - updated_at, context_window \ + cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \ + peak_cache_hit_input_per_m, updated_at, context_window \ FROM models "; /// GET /api/admin/models:全部模型(管理表格数据源) @@ -168,6 +175,7 @@ pub async fn create( req.output_per_m, req.vision, req.cache_hit_input_per_m, + req.cache_write_input_per_m, req.peak_input_per_m, req.peak_output_per_m, req.peak_cache_hit_input_per_m, @@ -190,9 +198,9 @@ pub async fn create( .query_row( "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, \ context_length, max_output, vision, cache_hit_input_per_m, \ - peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, \ - context_window, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now')) \ + cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \ + peak_cache_hit_input_per_m, context_window, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now')) \ RETURNING id", params![ provider, @@ -204,6 +212,7 @@ pub async fn create( req.max_output, req.vision, req.cache_hit_input_per_m, + req.cache_write_input_per_m, req.peak_input_per_m, req.peak_output_per_m, req.peak_cache_hit_input_per_m, @@ -242,6 +251,7 @@ pub async fn patch( )); } // 部分更新:先取当前值,合并后校验 + 写回(简单且避免逐列拼接 SQL) + #[allow(clippy::type_complexity)] // 价格平铺(13 元组),同 validate_common let cur: ( String, String, @@ -255,11 +265,13 @@ pub async fn patch( f64, f64, f64, + f64, ) = conn .query_row( "SELECT provider, model, currency, input_per_m, output_per_m, \ context_length, max_output, vision, cache_hit_input_per_m, \ - peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m \ + cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \ + peak_cache_hit_input_per_m \ FROM models WHERE id = ?1", [id], |r| { @@ -276,6 +288,7 @@ pub async fn patch( r.get(9)?, r.get(10)?, r.get(11)?, + r.get(12)?, )) }, ) @@ -289,9 +302,10 @@ pub async fn patch( let max_output = req.max_output.unwrap_or(cur.6); let vision = req.vision.unwrap_or(cur.7); let cache_hit_input_per_m = req.cache_hit_input_per_m.unwrap_or(cur.8); - let peak_input_per_m = req.peak_input_per_m.unwrap_or(cur.9); - let peak_output_per_m = req.peak_output_per_m.unwrap_or(cur.10); - let peak_cache_hit_input_per_m = req.peak_cache_hit_input_per_m.unwrap_or(cur.11); + let cache_write_input_per_m = req.cache_write_input_per_m.unwrap_or(cur.9); + let peak_input_per_m = req.peak_input_per_m.unwrap_or(cur.10); + let peak_output_per_m = req.peak_output_per_m.unwrap_or(cur.11); + let peak_cache_hit_input_per_m = req.peak_cache_hit_input_per_m.unwrap_or(cur.12); if provider.is_empty() || model.is_empty() { return Err(( axum::http::StatusCode::BAD_REQUEST, @@ -304,6 +318,7 @@ pub async fn patch( output_per_m, vision, cache_hit_input_per_m, + cache_write_input_per_m, peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, @@ -328,10 +343,11 @@ pub async fn patch( conn.execute( "UPDATE models SET provider = ?1, model = ?2, currency = ?3, input_per_m = ?4, \ output_per_m = ?5, context_length = ?6, max_output = ?7, vision = ?8, \ - cache_hit_input_per_m = ?9, peak_input_per_m = ?10, peak_output_per_m = ?11, \ - peak_cache_hit_input_per_m = ?12, context_window = ?6, tiers_json = '[]', \ + cache_hit_input_per_m = ?9, cache_write_input_per_m = ?10, \ + peak_input_per_m = ?11, peak_output_per_m = ?12, \ + peak_cache_hit_input_per_m = ?13, context_window = ?6, tiers_json = '[]', \ updated_at = datetime('now') \ - WHERE id = ?13", + WHERE id = ?14", params![ provider, model, @@ -342,6 +358,7 @@ pub async fn patch( max_output, vision, cache_hit_input_per_m, + cache_write_input_per_m, peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 818f30b..864849b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1307,6 +1307,124 @@ mod tests { assert_eq!(s, StatusCode::BAD_REQUEST, "高峰缓存命中价负数仍应 400"); } + /// #433 的 `cache_write_input_per_m` 必须与其余价格字段同权:管理接口能写、`GET` 能读回。 + /// 七个价各取**互不相同**的值 —— 任何一处列序错位(SELECT 与 `r.get(i)` 不同步)都会让 + /// 某个断言读到别人的价而立刻变红,这是本用例要挡的那类回归。 + #[tokio::test] + async fn admin_models_cache_write_price_round_trip() { + let st = test_state("admcachewrite"); + let admin_bearer = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + + // 带值创建 → POST 响应回读 + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"cw","model":"cw-billed","currency":"USD","input_per_m":1.0,"output_per_m":2.0,"cache_hit_input_per_m":0.5,"cache_write_input_per_m":0.25,"peak_input_per_m":1.5,"peak_output_per_m":3.0,"peak_cache_hit_input_per_m":0.75}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "带缓存写入价创建应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let id = v["id"].as_i64().unwrap(); + assert_eq!(v["cache_write_input_per_m"], 0.25, "POST 回读: {body}"); + assert_eq!(v["input_per_m"], 1.0, "兄弟价原位: {body}"); + assert_eq!(v["output_per_m"], 2.0, "兄弟价原位: {body}"); + assert_eq!(v["cache_hit_input_per_m"], 0.5, "兄弟价原位: {body}"); + assert_eq!(v["peak_input_per_m"], 1.5, "兄弟价原位: {body}"); + assert_eq!(v["peak_output_per_m"], 3.0, "兄弟价原位: {body}"); + assert_eq!(v["peak_cache_hit_input_per_m"], 0.75, "兄弟价原位: {body}"); + + // → GET 列表回读(列表走 dao::list_all_models,与单行 SELECT 是两条必须同步的读取路径) + let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await; + let arr: serde_json::Value = serde_json::from_str(&body).unwrap(); + { + let row = arr + .as_array() + .unwrap() + .iter() + .find(|m| m["id"] == id) + .expect("模型仍在"); + assert_eq!(row["cache_write_input_per_m"], 0.25, "GET 回读: {body}"); + assert_eq!(row["input_per_m"], 1.0, "GET 兄弟价原位: {body}"); + assert_eq!(row["cache_hit_input_per_m"], 0.5, "GET 兄弟价原位: {body}"); + assert_eq!( + row["peak_cache_hit_input_per_m"], 0.75, + "GET 兄弟价原位: {body}" + ); + } + + // 不带该字段创建 → 0(缺省=不收费;刻意**不**回落 input_per_m,否则未选入的部署会被重算价) + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"cw","model":"cw-default","currency":"USD","input_per_m":8.0,"output_per_m":9.0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "缺该字段创建应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["cache_write_input_per_m"], 0.0, "缺省 0 而非 8.0: {body}"); + + // PATCH 只改该价 → 生效,且其余价一律不动 + let (s, body) = patch( + st.clone(), + &format!("/api/admin/models/{id}"), + r#"{"cache_write_input_per_m":3.5}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "PATCH 缓存写入价应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["cache_write_input_per_m"], 3.5, "PATCH 回读: {body}"); + assert_eq!(v["input_per_m"], 1.0, "PATCH 不得动兄弟价: {body}"); + assert_eq!( + v["cache_hit_input_per_m"], 0.5, + "PATCH 不得动命中价: {body}" + ); + assert_eq!( + v["peak_cache_hit_input_per_m"], 0.75, + "PATCH 不得动高峰命中价: {body}" + ); + + // PATCH 负数 → 400,且库中该值不被写入 + let (s, body) = patch( + st.clone(), + &format!("/api/admin/models/{id}"), + r#"{"cache_write_input_per_m":-2.0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "负数缓存写入价应 400: {body}"); + let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await; + let arr: serde_json::Value = serde_json::from_str(&body).unwrap(); + let row = arr + .as_array() + .unwrap() + .iter() + .find(|m| m["id"] == id) + .expect("模型仍在"); + assert_eq!( + row["cache_write_input_per_m"], 3.5, + "被拒的 PATCH 不得改库: {body}" + ); + + // 创建时负数同样被拒(同一校验函数,两条入口都要有牙) + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"cw","model":"cw-negative","currency":"USD","cache_write_input_per_m":-0.5}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!( + s, + StatusCode::BAD_REQUEST, + "创建负数缓存写入价应 400: {body}" + ); + let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await; + assert!(!body.contains("cw-negative"), "被拒的创建不得落库: {body}"); + } + #[tokio::test] async fn market_models_include_new_fields() { // GET /api/models 响应补 context_length/max_output/vision/cache_hit_input_per_m