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
12 changes: 7 additions & 5 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,12 @@ pub fn list_all_models(conn: &Connection) -> Result<Vec<serde_json::Value>> {
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)?,
Expand All @@ -305,9 +306,10 @@ pub fn list_all_models(conn: &Connection) -> Result<Vec<serde_json::Value>> {
"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),
}))
})?;
Expand Down
57 changes: 37 additions & 20 deletions src/routes/admin_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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**;删除的后果由路由入口的
Expand Down Expand Up @@ -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,
Expand All @@ -65,19 +68,21 @@ pub struct ModelPatch {
pub max_output: Option<i64>,
pub vision: Option<i64>,
pub cache_hit_input_per_m: Option<f64>,
pub cache_write_input_per_m: Option<f64>,
pub peak_input_per_m: Option<f64>,
pub peak_output_per_m: Option<f64>,
pub peak_cache_hit_input_per_m: Option<f64>,
}

/// 校验通用字段:价格 ≥ 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,
Expand All @@ -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
Expand Down Expand Up @@ -122,18 +128,19 @@ fn row_to_json(r: &rusqlite::Row) -> rusqlite::Result<serde_json::Value> {
"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:全部模型(管理表格数据源)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -242,6 +251,7 @@ pub async fn patch(
));
}
// 部分更新:先取当前值,合并后校验 + 写回(简单且避免逐列拼接 SQL)
#[allow(clippy::type_complexity)] // 价格平铺(13 元组),同 validate_common
let cur: (
String,
String,
Expand All @@ -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| {
Expand All @@ -276,6 +288,7 @@ pub async fn patch(
r.get(9)?,
r.get(10)?,
r.get(11)?,
r.get(12)?,
))
},
)
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
118 changes: 118 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading