Skip to content

Commit 41ca047

Browse files
argszeroargszero
andauthored
feat(admin): accept the cache-write price in the model editor (#434)
#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. Co-authored-by: argszero <argszero@argszerodeMac-mini.local>
1 parent 357ab01 commit 41ca047

3 files changed

Lines changed: 162 additions & 25 deletions

File tree

‎src/dao.rs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,11 +289,12 @@ pub fn list_all_models(conn: &Connection) -> Result<Vec<serde_json::Value>> {
289289
let mut stmt = conn.prepare(
290290
"SELECT id, provider, model, currency, input_per_m, output_per_m, \
291291
context_length, max_output, vision, cache_hit_input_per_m, \
292-
peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, updated_at \
292+
cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \
293+
peak_cache_hit_input_per_m, updated_at \
293294
FROM models ORDER BY provider, model",
294295
)?;
295296
let rows = stmt.query_map([], |r| {
296-
let updated_at: String = r.get(13)?;
297+
let updated_at: String = r.get(14)?;
297298
Ok(serde_json::json!({
298299
"id": r.get::<_, i64>(0)?,
299300
"provider": r.get::<_, String>(1)?,
@@ -305,9 +306,10 @@ pub fn list_all_models(conn: &Connection) -> Result<Vec<serde_json::Value>> {
305306
"max_output": r.get::<_, i64>(7)?,
306307
"vision": r.get::<_, i64>(8)?,
307308
"cache_hit_input_per_m": r.get::<_, f64>(9)?,
308-
"peak_input_per_m": r.get::<_, f64>(10)?,
309-
"peak_output_per_m": r.get::<_, f64>(11)?,
310-
"peak_cache_hit_input_per_m": r.get::<_, f64>(12)?,
309+
"cache_write_input_per_m": r.get::<_, f64>(10)?,
310+
"peak_input_per_m": r.get::<_, f64>(11)?,
311+
"peak_output_per_m": r.get::<_, f64>(12)?,
312+
"peak_cache_hit_input_per_m": r.get::<_, f64>(13)?,
311313
"updated_at": utc_iso(&updated_at),
312314
}))
313315
})?;

‎src/routes/admin_models.rs‎

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
//!
33
//! - GET /api/admin/models:全部模型列表(含 id / 所有字段,供管理表格)
44
//! - POST /api/admin/models:新增 {provider, model, currency, input_per_m, output_per_m,
5-
//! context_length?, max_output?, vision?, cache_hit_input_per_m?, peak_input_per_m?,
6-
//! peak_output_per_m?, peak_cache_hit_input_per_m?}
5+
//! context_length?, max_output?, vision?, cache_hit_input_per_m?, cache_write_input_per_m?,
6+
//! peak_input_per_m?, peak_output_per_m?, peak_cache_hit_input_per_m?}
77
//! 校验:provider/model 非空、价格 ≥ 0、currency ∈ {USD, CNY}、(provider, model) 唯一冲突 409
88
//! - PATCH /api/admin/models/:id:部分更新(任意字段)
99
//! - DELETE /api/admin/models/:id:直接删除(只删目录行、**不改写 keys**;删除的后果由路由入口的
@@ -40,6 +40,9 @@ pub struct ModelCreate {
4040
pub vision: i64,
4141
#[serde(default)]
4242
pub cache_hit_input_per_m: f64,
43+
/// 缓存写入(cache-creation)输入价(#433;缺省 0 = 缓存写入不收费)
44+
#[serde(default)]
45+
pub cache_write_input_per_m: f64,
4346
/// 高峰时段价(rant 2026-08-20T11:58:40;缺省 0 = 不启用高峰计费)
4447
#[serde(default)]
4548
pub peak_input_per_m: f64,
@@ -65,19 +68,21 @@ pub struct ModelPatch {
6568
pub max_output: Option<i64>,
6669
pub vision: Option<i64>,
6770
pub cache_hit_input_per_m: Option<f64>,
71+
pub cache_write_input_per_m: Option<f64>,
6872
pub peak_input_per_m: Option<f64>,
6973
pub peak_output_per_m: Option<f64>,
7074
pub peak_cache_hit_input_per_m: Option<f64>,
7175
}
7276

7377
/// 校验通用字段:价格 ≥ 0、vision ∈ {0,1}、currency 枚举
74-
#[allow(clippy::too_many_arguments)] // 价格字段平铺(6 个价格 + currency + vision),保持与请求结构体同序
78+
#[allow(clippy::too_many_arguments)] // 价格字段平铺(7 个价格 + currency + vision),保持与请求结构体同序
7579
fn validate_common(
7680
currency: &str,
7781
input_per_m: f64,
7882
output_per_m: f64,
7983
vision: i64,
8084
cache_hit_input_per_m: f64,
85+
cache_write_input_per_m: f64,
8186
peak_input_per_m: f64,
8287
peak_output_per_m: f64,
8388
peak_cache_hit_input_per_m: f64,
@@ -91,6 +96,7 @@ fn validate_common(
9196
if input_per_m < 0.0
9297
|| output_per_m < 0.0
9398
|| cache_hit_input_per_m < 0.0
99+
|| cache_write_input_per_m < 0.0
94100
|| peak_input_per_m < 0.0
95101
|| peak_output_per_m < 0.0
96102
|| peak_cache_hit_input_per_m < 0.0
@@ -122,18 +128,19 @@ fn row_to_json(r: &rusqlite::Row) -> rusqlite::Result<serde_json::Value> {
122128
"max_output": r.get::<_, i64>(7)?,
123129
"vision": r.get::<_, i64>(8)?,
124130
"cache_hit_input_per_m": r.get::<_, f64>(9)?,
125-
"peak_input_per_m": r.get::<_, f64>(10)?,
126-
"peak_output_per_m": r.get::<_, f64>(11)?,
127-
"peak_cache_hit_input_per_m": r.get::<_, f64>(12)?,
128-
"updated_at": crate::dao::utc_iso(&r.get::<_, String>(13)?),
129-
"context_window": r.get::<_, i64>(14)?,
131+
"cache_write_input_per_m": r.get::<_, f64>(10)?,
132+
"peak_input_per_m": r.get::<_, f64>(11)?,
133+
"peak_output_per_m": r.get::<_, f64>(12)?,
134+
"peak_cache_hit_input_per_m": r.get::<_, f64>(13)?,
135+
"updated_at": crate::dao::utc_iso(&r.get::<_, String>(14)?),
136+
"context_window": r.get::<_, i64>(15)?,
130137
}))
131138
}
132139

133140
const ROW_SELECT: &str = "SELECT id, provider, model, currency, input_per_m, output_per_m, \
134141
context_length, max_output, vision, cache_hit_input_per_m, \
135-
peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, \
136-
updated_at, context_window \
142+
cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \
143+
peak_cache_hit_input_per_m, updated_at, context_window \
137144
FROM models ";
138145

139146
/// GET /api/admin/models:全部模型(管理表格数据源)
@@ -168,6 +175,7 @@ pub async fn create(
168175
req.output_per_m,
169176
req.vision,
170177
req.cache_hit_input_per_m,
178+
req.cache_write_input_per_m,
171179
req.peak_input_per_m,
172180
req.peak_output_per_m,
173181
req.peak_cache_hit_input_per_m,
@@ -190,9 +198,9 @@ pub async fn create(
190198
.query_row(
191199
"INSERT INTO models (provider, model, currency, input_per_m, output_per_m, \
192200
context_length, max_output, vision, cache_hit_input_per_m, \
193-
peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, \
194-
context_window, updated_at) \
195-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now')) \
201+
cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \
202+
peak_cache_hit_input_per_m, context_window, updated_at) \
203+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now')) \
196204
RETURNING id",
197205
params![
198206
provider,
@@ -204,6 +212,7 @@ pub async fn create(
204212
req.max_output,
205213
req.vision,
206214
req.cache_hit_input_per_m,
215+
req.cache_write_input_per_m,
207216
req.peak_input_per_m,
208217
req.peak_output_per_m,
209218
req.peak_cache_hit_input_per_m,
@@ -242,6 +251,7 @@ pub async fn patch(
242251
));
243252
}
244253
// 部分更新:先取当前值,合并后校验 + 写回(简单且避免逐列拼接 SQL)
254+
#[allow(clippy::type_complexity)] // 价格平铺(13 元组),同 validate_common
245255
let cur: (
246256
String,
247257
String,
@@ -255,11 +265,13 @@ pub async fn patch(
255265
f64,
256266
f64,
257267
f64,
268+
f64,
258269
) = conn
259270
.query_row(
260271
"SELECT provider, model, currency, input_per_m, output_per_m, \
261272
context_length, max_output, vision, cache_hit_input_per_m, \
262-
peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m \
273+
cache_write_input_per_m, peak_input_per_m, peak_output_per_m, \
274+
peak_cache_hit_input_per_m \
263275
FROM models WHERE id = ?1",
264276
[id],
265277
|r| {
@@ -276,6 +288,7 @@ pub async fn patch(
276288
r.get(9)?,
277289
r.get(10)?,
278290
r.get(11)?,
291+
r.get(12)?,
279292
))
280293
},
281294
)
@@ -289,9 +302,10 @@ pub async fn patch(
289302
let max_output = req.max_output.unwrap_or(cur.6);
290303
let vision = req.vision.unwrap_or(cur.7);
291304
let cache_hit_input_per_m = req.cache_hit_input_per_m.unwrap_or(cur.8);
292-
let peak_input_per_m = req.peak_input_per_m.unwrap_or(cur.9);
293-
let peak_output_per_m = req.peak_output_per_m.unwrap_or(cur.10);
294-
let peak_cache_hit_input_per_m = req.peak_cache_hit_input_per_m.unwrap_or(cur.11);
305+
let cache_write_input_per_m = req.cache_write_input_per_m.unwrap_or(cur.9);
306+
let peak_input_per_m = req.peak_input_per_m.unwrap_or(cur.10);
307+
let peak_output_per_m = req.peak_output_per_m.unwrap_or(cur.11);
308+
let peak_cache_hit_input_per_m = req.peak_cache_hit_input_per_m.unwrap_or(cur.12);
295309
if provider.is_empty() || model.is_empty() {
296310
return Err((
297311
axum::http::StatusCode::BAD_REQUEST,
@@ -304,6 +318,7 @@ pub async fn patch(
304318
output_per_m,
305319
vision,
306320
cache_hit_input_per_m,
321+
cache_write_input_per_m,
307322
peak_input_per_m,
308323
peak_output_per_m,
309324
peak_cache_hit_input_per_m,
@@ -328,10 +343,11 @@ pub async fn patch(
328343
conn.execute(
329344
"UPDATE models SET provider = ?1, model = ?2, currency = ?3, input_per_m = ?4, \
330345
output_per_m = ?5, context_length = ?6, max_output = ?7, vision = ?8, \
331-
cache_hit_input_per_m = ?9, peak_input_per_m = ?10, peak_output_per_m = ?11, \
332-
peak_cache_hit_input_per_m = ?12, context_window = ?6, tiers_json = '[]', \
346+
cache_hit_input_per_m = ?9, cache_write_input_per_m = ?10, \
347+
peak_input_per_m = ?11, peak_output_per_m = ?12, \
348+
peak_cache_hit_input_per_m = ?13, context_window = ?6, tiers_json = '[]', \
333349
updated_at = datetime('now') \
334-
WHERE id = ?13",
350+
WHERE id = ?14",
335351
params![
336352
provider,
337353
model,
@@ -342,6 +358,7 @@ pub async fn patch(
342358
max_output,
343359
vision,
344360
cache_hit_input_per_m,
361+
cache_write_input_per_m,
345362
peak_input_per_m,
346363
peak_output_per_m,
347364
peak_cache_hit_input_per_m,

‎src/routes/mod.rs‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,124 @@ mod tests {
13071307
assert_eq!(s, StatusCode::BAD_REQUEST, "高峰缓存命中价负数仍应 400");
13081308
}
13091309

1310+
/// #433 的 `cache_write_input_per_m` 必须与其余价格字段同权:管理接口能写、`GET` 能读回。
1311+
/// 七个价各取**互不相同**的值 —— 任何一处列序错位(SELECT 与 `r.get(i)` 不同步)都会让
1312+
/// 某个断言读到别人的价而立刻变红,这是本用例要挡的那类回归。
1313+
#[tokio::test]
1314+
async fn admin_models_cache_write_price_round_trip() {
1315+
let st = test_state("admcachewrite");
1316+
let admin_bearer = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await;
1317+
1318+
// 带值创建 → POST 响应回读
1319+
let (s, body) = post(
1320+
st.clone(),
1321+
"/api/admin/models",
1322+
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}"#,
1323+
Some(&admin_bearer),
1324+
)
1325+
.await;
1326+
assert_eq!(s, StatusCode::OK, "带缓存写入价创建应 200: {body}");
1327+
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
1328+
let id = v["id"].as_i64().unwrap();
1329+
assert_eq!(v["cache_write_input_per_m"], 0.25, "POST 回读: {body}");
1330+
assert_eq!(v["input_per_m"], 1.0, "兄弟价原位: {body}");
1331+
assert_eq!(v["output_per_m"], 2.0, "兄弟价原位: {body}");
1332+
assert_eq!(v["cache_hit_input_per_m"], 0.5, "兄弟价原位: {body}");
1333+
assert_eq!(v["peak_input_per_m"], 1.5, "兄弟价原位: {body}");
1334+
assert_eq!(v["peak_output_per_m"], 3.0, "兄弟价原位: {body}");
1335+
assert_eq!(v["peak_cache_hit_input_per_m"], 0.75, "兄弟价原位: {body}");
1336+
1337+
// → GET 列表回读(列表走 dao::list_all_models,与单行 SELECT 是两条必须同步的读取路径)
1338+
let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await;
1339+
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
1340+
{
1341+
let row = arr
1342+
.as_array()
1343+
.unwrap()
1344+
.iter()
1345+
.find(|m| m["id"] == id)
1346+
.expect("模型仍在");
1347+
assert_eq!(row["cache_write_input_per_m"], 0.25, "GET 回读: {body}");
1348+
assert_eq!(row["input_per_m"], 1.0, "GET 兄弟价原位: {body}");
1349+
assert_eq!(row["cache_hit_input_per_m"], 0.5, "GET 兄弟价原位: {body}");
1350+
assert_eq!(
1351+
row["peak_cache_hit_input_per_m"], 0.75,
1352+
"GET 兄弟价原位: {body}"
1353+
);
1354+
}
1355+
1356+
// 不带该字段创建 → 0(缺省=不收费;刻意**不**回落 input_per_m,否则未选入的部署会被重算价)
1357+
let (s, body) = post(
1358+
st.clone(),
1359+
"/api/admin/models",
1360+
r#"{"provider":"cw","model":"cw-default","currency":"USD","input_per_m":8.0,"output_per_m":9.0}"#,
1361+
Some(&admin_bearer),
1362+
)
1363+
.await;
1364+
assert_eq!(s, StatusCode::OK, "缺该字段创建应 200: {body}");
1365+
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
1366+
assert_eq!(v["cache_write_input_per_m"], 0.0, "缺省 0 而非 8.0: {body}");
1367+
1368+
// PATCH 只改该价 → 生效,且其余价一律不动
1369+
let (s, body) = patch(
1370+
st.clone(),
1371+
&format!("/api/admin/models/{id}"),
1372+
r#"{"cache_write_input_per_m":3.5}"#,
1373+
Some(&admin_bearer),
1374+
)
1375+
.await;
1376+
assert_eq!(s, StatusCode::OK, "PATCH 缓存写入价应 200: {body}");
1377+
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
1378+
assert_eq!(v["cache_write_input_per_m"], 3.5, "PATCH 回读: {body}");
1379+
assert_eq!(v["input_per_m"], 1.0, "PATCH 不得动兄弟价: {body}");
1380+
assert_eq!(
1381+
v["cache_hit_input_per_m"], 0.5,
1382+
"PATCH 不得动命中价: {body}"
1383+
);
1384+
assert_eq!(
1385+
v["peak_cache_hit_input_per_m"], 0.75,
1386+
"PATCH 不得动高峰命中价: {body}"
1387+
);
1388+
1389+
// PATCH 负数 → 400,且库中该值不被写入
1390+
let (s, body) = patch(
1391+
st.clone(),
1392+
&format!("/api/admin/models/{id}"),
1393+
r#"{"cache_write_input_per_m":-2.0}"#,
1394+
Some(&admin_bearer),
1395+
)
1396+
.await;
1397+
assert_eq!(s, StatusCode::BAD_REQUEST, "负数缓存写入价应 400: {body}");
1398+
let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await;
1399+
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
1400+
let row = arr
1401+
.as_array()
1402+
.unwrap()
1403+
.iter()
1404+
.find(|m| m["id"] == id)
1405+
.expect("模型仍在");
1406+
assert_eq!(
1407+
row["cache_write_input_per_m"], 3.5,
1408+
"被拒的 PATCH 不得改库: {body}"
1409+
);
1410+
1411+
// 创建时负数同样被拒(同一校验函数,两条入口都要有牙)
1412+
let (s, body) = post(
1413+
st.clone(),
1414+
"/api/admin/models",
1415+
r#"{"provider":"cw","model":"cw-negative","currency":"USD","cache_write_input_per_m":-0.5}"#,
1416+
Some(&admin_bearer),
1417+
)
1418+
.await;
1419+
assert_eq!(
1420+
s,
1421+
StatusCode::BAD_REQUEST,
1422+
"创建负数缓存写入价应 400: {body}"
1423+
);
1424+
let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await;
1425+
assert!(!body.contains("cw-negative"), "被拒的创建不得落库: {body}");
1426+
}
1427+
13101428
#[tokio::test]
13111429
async fn market_models_include_new_fields() {
13121430
// GET /api/models 响应补 context_length/max_output/vision/cache_hit_input_per_m

0 commit comments

Comments
 (0)