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
7 changes: 6 additions & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,12 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()>
)?;
n += 1;
}
// 同步删除 config 中已移除的模型(模型行无外键引用,usage_records/transactions 均按 model 字符串记)
// 同步删除 config 中已移除的模型。⚠️ **不变量**:`models` 行是路由键 `keys.model` 的**计价依赖** ——
// 一把 `status='on'` 的 key 若其 (provider, model) 在目录里没有行,调用仍可被路由,但结算取价会落
// `None` ⇒ 静默免费(R156)。因此**删除/改名一个 (provider, model) 之前,必须处理引用它的 key**;
// 本函数与 `admin_models::{remove, patch}` 是全部三个写入者,都受此约束。权威执行点是路由入口
// (`gateway::forward{,_stream}` 在发往上游前要求该 (provider, model) 可取价)与创建入口
// (`sharing::create`);此处只记约束、**不做数据清理** —— `keys` 是用户数据,绝不因目录变动被改写。
let mut removed = 0u32;
let stale: Vec<(String, String)> = {
let mut stmt = conn
Expand Down
93 changes: 92 additions & 1 deletion src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,20 @@ fn settle_usage(
);
(pts, cost)
}
None => (0.0, 0.0),
None => {
// 计价门(R156):`forward`/`forward_stream` 已在发往上游之前滤掉取不到价的
// (provider, model),所以正常路径不该走到这里。真到了说明目录在「预检取价」与
// 「结算」之间变了,或有别的调用点绕过了预检 —— 此时按 0 计费等于把这次真实
// 调用白送出去,必须留下可追溯的痕迹(「缺数据」不能和「数据为 0」共用同一
// 载体,见 stream-usage-billing 轴),而不是静默免费。
log::error!(
"计价失败(models 目录中无 provider/model)key_id={} provider={} model={} ⇒ 本次按 0 计费",
key.id,
key.provider,
model
);
(0.0, 0.0)
}
};
let params = billing::SettleParams {
consumer_id: auth.user_id,
Expand Down Expand Up @@ -262,6 +275,27 @@ async fn forward(
));
}

// 计价门(R156):路由只认 `model`(`find_keys_by_model` 的 SQL 里没有 provider),计费却按
// `(key.provider, model)` 查 `models` 目录 —— 而**目录变更不维护 `keys`**:管理页改
// provider/model、删目录行,或 config 收缩触发的启动同步,都能让一把 `status='on'` 的 key
// 落到「有 key、没有价」的状态。那种调用会 200 正常返回,而 `settle_usage` 取到
// `None => (0.0, 0.0)` ⇒ 消费者不扣点、分享者无收益:一次真实调用静默变成免费。
// 创建入口(C2065 `sharing::create`)已拒绝这样的 (provider, model);这里是同一不变量的
// 第二扇门,覆盖目录变更这条路径 —— 候选 key 逐个对目录取价,取不到的**不进候选集**
// (否则粘性/随机可能正好选中它),全被滤掉即该模型不可计价:**发往上游之前**就拒绝。
let keys: Vec<dao::KeyRow> = {
let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?;
keys.into_iter()
.filter(|k| dao::get_model_price(&conn, &k.provider, model).is_some())
.collect()
};
if keys.is_empty() {
return Err(err_json(
StatusCode::SERVICE_UNAVAILABLE,
"provider 与 model 不在模型目录中,无法计价",
));
}

for _ in 0..crate::router::MAX_SWITCHES {
// 选 key(粘性优先 / 随机 / 排除冷却)
let Some(key_id) = st.router.pick(&keys, auth.user_id, model) else {
Expand Down Expand Up @@ -474,6 +508,22 @@ async fn forward_stream(
));
}

// 计价门(R156,与 `forward` 同一条不变量、同一句话):候选 key 必须能在 `models`
// 目录里取到 (provider, model) 的价,否则这次调用会在 `settle_usage` 里落到
// `None => (0.0, 0.0)` 而静默免费 —— 流式与非流式是两扇门,必须同口径。
let keys: Vec<dao::KeyRow> = {
let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?;
keys.into_iter()
.filter(|k| dao::get_model_price(&conn, &k.provider, model).is_some())
.collect()
};
if keys.is_empty() {
return Err(err_json(
StatusCode::SERVICE_UNAVAILABLE,
"provider 与 model 不在模型目录中,无法计价",
));
}

// 选 key 并建立上游连接(此阶段失败可切换;连接成功后不再切换)
for _ in 0..crate::router::MAX_SWITCHES {
let Some(key_id) = st.router.pick(&keys, auth.user_id, model) else {
Expand Down Expand Up @@ -1375,6 +1425,13 @@ mod tests {
async fn dead_upstream_failover_503() {
// 两个 key 都指向不可达端口 → 3 次尝试全失败 → 503 + 冷却 2 个 key
let st = test_state("dead", "test-dead", "http://127.0.0.1:9");
// R156:计价门要求 (provider, model) 在目录里可取价,否则在发往上游前就被拒(503)。
// 本测试要的是**上游全挂**那条路径,故先给 `dead-model` 一行价(`insert_key` 的 provider
// 是 'test')。
{
let conn = st.db.lock().unwrap();
models_row(&conn, "test", "dead-model", 10.0, 20.0);
}
insert_key(&st, 100, 1, "dead-model", "test-dead");
insert_key(&st, 101, 1, "dead-model", "test-dead");
let key = login_key(st.clone()).await;
Expand All @@ -1399,6 +1456,40 @@ mod tests {
assert_eq!(st.router.cooldown_len(), 2, "两次失败后两个 key 均进入冷却");
}

#[tokio::test]
async fn unpriced_model_503_before_upstream() {
// R156:「可路由」与「可计价」是两扇门。key 在(model 匹配、status=on),但
// (provider, model) 不在 `models` 目录里 ⇒ 必须在**发往上游之前**拒绝,而不是照常
// 转发、结算时落到 `None => (0.0, 0.0)` 静默免费。
let st = test_state("unpriced", "test-unpriced", "http://127.0.0.1:9");
insert_key(&st, 200, 1, "no-price-model", "test-unpriced");
let key = login_key(st.clone()).await;
let (s, body) = post_raw(
st.clone(),
"/v1/chat/completions",
r#"{"model":"no-price-model"}"#,
Some(&key),
)
.await;
assert_eq!(
s,
StatusCode::SERVICE_UNAVAILABLE,
"body: {}",
String::from_utf8_lossy(&body)
);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
v["error"]["message"].as_str().unwrap().contains("无法计价"),
"body: {}",
String::from_utf8_lossy(&body)
);
assert_eq!(
st.router.cooldown_len(),
0,
"被计价门拒绝的调用不该碰上游,更不该把 key 打进冷却"
);
}

#[tokio::test]
async fn e2e_success_ledger_and_sticky() {
// 起假上游
Expand Down
Loading
Loading