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
9 changes: 5 additions & 4 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,11 @@ endpoints = [
# 模型(厂商/名称/价格/上下文/读图等)全部在此定义;启动时 upsert 进 DB。
# 单位:每百万 tokens(currency 对应币种);input_per_m = 缓存未命中输入价,
# cache_hit_input_per_m = 缓存命中输入价(缺省 0 = 命中免费)。
# 缓存写入价(rant 2026-10-16T14:08:14):可选 cache_write_input_per_m(每百万 tokens),
# 缺省 0 = 缓存写入不收费(与今天一致)。⚠️ 这个字段目前**只表达、不收费**:上游响应里
# 没有取「缓存写入 token 数」的通路(gateway parse_usage / sse 都不产出它),接进计费要
# 另加一次用量解析,属后续改动。
# 缓存写入价(rant 2026-10-16T14:08:14;#432 起**参与计费**):可选
# cache_write_input_per_m(每百万 tokens),缺省 0 = 缓存写入不收费。计费口径 = 上游报的
# 缓存写入 token 数 × 本字段;缺省 0 时收 0 元,与未接入前完全一致。
# 只有 anthropic 的上游响应带这个 token 数(usage.cache_creation_input_tokens),其它协议
# 按 0 处理 —— 拿不到就不收,不估算。分档价与高峰价都不带这一档 ⇒ 它不随选档或时段变化。
# 分档价(rant 2026-10-16T14:08:14 第 2 步:settle 已按请求输入 token 数选档):
# 可选 [[models.tiers]] 数组按**单次请求输入 token 数**分档 —— 输入 = 缓存未命中 + 缓存
# 命中那一段 prompt(不是用户累计用量,也不是输出)。from_tokens 是本档下界(含),各档
Expand Down
16 changes: 10 additions & 6 deletions src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub const TRANSACTIONS: Spec = Spec {
"tokens",
"cached_tokens",
"output_tokens",
"cache_write_tokens",
"pts",
"type",
"status",
Expand All @@ -103,6 +104,7 @@ pub const USAGE_RECORDS: Spec = Spec {
"tokens",
"cached_tokens",
"output_tokens",
"cache_write_tokens",
"cost",
"time",
],
Expand Down Expand Up @@ -267,10 +269,11 @@ fn tx_json(row: &rusqlite::Row<'_>) -> rusqlite::Result<String> {
"tokens": row.get::<_, f64>(6)?,
"cached_tokens": row.get::<_, f64>(7)?,
"output_tokens": row.get::<_, f64>(8)?,
"pts": row.get::<_, f64>(9)?,
"type": row.get::<_, String>(10)?,
"status": row.get::<_, String>(11)?,
"time": row.get::<_, String>(12)?,
"cache_write_tokens": row.get::<_, f64>(9)?,
"pts": row.get::<_, f64>(10)?,
"type": row.get::<_, String>(11)?,
"status": row.get::<_, String>(12)?,
"time": row.get::<_, String>(13)?,
});
Ok(v.to_string())
}
Expand All @@ -286,8 +289,9 @@ fn usage_json(row: &rusqlite::Row<'_>) -> rusqlite::Result<String> {
"tokens": row.get::<_, f64>(5)?,
"cached_tokens": row.get::<_, f64>(6)?,
"output_tokens": row.get::<_, f64>(7)?,
"cost": row.get::<_, f64>(8)?,
"time": row.get::<_, String>(9)?,
"cache_write_tokens": row.get::<_, f64>(8)?,
"cost": row.get::<_, f64>(9)?,
"time": row.get::<_, String>(10)?,
});
Ok(v.to_string())
}
Expand Down
90 changes: 77 additions & 13 deletions src/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,29 @@ pub fn to_anchor(amount: f64, model_currency: &str, anchor: &str) -> f64 {

/// 原始成本(模型自身货币):USD/CNY 每百万 token 单价 × token 数。
/// 输入分「缓存未命中」与「缓存命中」两档价(rant 2026-08-20T10:17:27):
/// 未命中 × input_per_m + 命中 × cache_hit_input_per_m(缺省 0 → 命中部分免费)
/// 未命中 × input_per_m + 命中 × cache_hit_input_per_m(缺省 0 → 命中部分免费)。
///
/// 缓存写入(cache creation)是**第三档**输入价(#432):`cache_write_tokens` ×
/// `cache_write_input_per_m`。它是把 prompt 写进上游缓存的那一次收费,与「命中」互斥,
/// 也不与未命中重叠 —— 上游上报的三段本来就互斥。缺省 0 ⇒ 收 0 元,与 #432 之前的账完全一致。
///
/// 缓存写入价**不随选档变化**:`ModelTier` 只带输入/命中/输出三档价,故档位切换不影响它,
/// 也没有高峰时段变体(config 只有 `peak_*` 三个字段)。要放开这两条得先有配置字段。
#[allow(clippy::too_many_arguments)] // 与 calc_points 同:token 段与单价平铺,保持可读
pub fn raw_cost(
input_tokens: f64,
cached_tokens: f64,
output_tokens: f64,
cache_write_tokens: f64,
input_per_m: f64,
cache_hit_input_per_m: f64,
output_per_m: f64,
cache_write_input_per_m: f64,
) -> f64 {
input_tokens * input_per_m / 1_000_000.0
+ cached_tokens * cache_hit_input_per_m / 1_000_000.0
+ output_tokens * output_per_m / 1_000_000.0
+ cache_write_tokens * cache_write_input_per_m / 1_000_000.0
}

/// 计算点数:锚定货币成本 × points_per_unit
Expand All @@ -60,9 +71,11 @@ pub fn calc_points(
input_tokens: f64,
cached_tokens: f64,
output_tokens: f64,
cache_write_tokens: f64,
input_per_m: f64,
cache_hit_input_per_m: f64,
output_per_m: f64,
cache_write_input_per_m: f64,
points_per_unit: u32,
model_currency: &str,
anchor: &str,
Expand All @@ -71,9 +84,11 @@ pub fn calc_points(
input_tokens,
cached_tokens,
output_tokens,
cache_write_tokens,
input_per_m,
cache_hit_input_per_m,
output_per_m,
cache_write_input_per_m,
);
round5(to_anchor(cost, model_currency, anchor) * points_per_unit as f64)
}
Expand Down Expand Up @@ -176,6 +191,9 @@ pub struct SettleParams {
pub cached_tokens: f64,
/// 输出 token 数(v0.7.4,rant 2026-08-21T14:53:20:单次调用明细 输入/缓存/输出)
pub output_tokens: f64,
/// 缓存写入(cache creation)token 数(#432)。**不进 `tokens`**:`tokens` 仍是
/// input + cached + output,读侧有 5 处按那个和反推「输入」(见 `db::migrate` 的 v18 注)。
pub cache_write_tokens: f64,
/// 消费者应扣点数
pub pts: f64,
/// 锚定货币成本(usage_records.cost)
Expand Down Expand Up @@ -210,8 +228,8 @@ pub fn settle(conn: &mut Connection, p: &SettleParams) -> Result<()> {
// transactions:consume(消费者)+ earn(分享者),counterpart 记对方;
// api_key_id 记录分发 key(v11,rant 2026-08-22T17:21:39 需求 2:Key 列显示 api_keys.name)
tx.execute(
"INSERT INTO transactions (user_id, counterpart, key_id, api_key_id, model, tokens, cached_tokens, output_tokens, pts, type, status) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'consume', '成功')",
"INSERT INTO transactions (user_id, counterpart, key_id, api_key_id, model, tokens, cached_tokens, output_tokens, cache_write_tokens, pts, type, status) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'consume', '成功')",
rusqlite::params![
p.consumer_id,
p.owner_id.to_string(),
Expand All @@ -221,12 +239,13 @@ pub fn settle(conn: &mut Connection, p: &SettleParams) -> Result<()> {
p.tokens,
p.cached_tokens,
p.output_tokens,
p.cache_write_tokens,
p.pts
],
)?;
tx.execute(
"INSERT INTO transactions (user_id, counterpart, key_id, api_key_id, model, tokens, cached_tokens, output_tokens, pts, type, status) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'earn', '成功')",
"INSERT INTO transactions (user_id, counterpart, key_id, api_key_id, model, tokens, cached_tokens, output_tokens, cache_write_tokens, pts, type, status) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'earn', '成功')",
rusqlite::params![
p.owner_id,
p.consumer_id.to_string(),
Expand All @@ -236,14 +255,15 @@ pub fn settle(conn: &mut Connection, p: &SettleParams) -> Result<()> {
p.tokens,
p.cached_tokens,
p.output_tokens,
p.cache_write_tokens,
earn
],
)?;

// usage_records
tx.execute(
"INSERT INTO usage_records (user_id, api_key_id, key_id, model, tokens, cached_tokens, output_tokens, cost) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
"INSERT INTO usage_records (user_id, api_key_id, key_id, model, tokens, cached_tokens, output_tokens, cache_write_tokens, cost) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
p.consumer_id,
p.api_key_id,
Expand All @@ -252,6 +272,7 @@ pub fn settle(conn: &mut Connection, p: &SettleParams) -> Result<()> {
p.tokens,
p.cached_tokens,
p.output_tokens,
p.cache_write_tokens,
p.cost
],
)?;
Expand Down Expand Up @@ -305,17 +326,17 @@ mod tests {
fn calc_points_basic() {
// 100 prompt × 10 USD/M + 50 completion × 20 USD/M = 0.002 USD
// → CNY 锚定 ×7.2 = 0.0144 CNY → 1 点/CNY = 0.0144 点(保留 5 位小数)
let pts = calc_points(100.0, 0.0, 50.0, 10.0, 0.0, 20.0, 1, "USD", "CNY");
let pts = calc_points(100.0, 0.0, 50.0, 0.0, 10.0, 0.0, 20.0, 0.0, 1, "USD", "CNY");
assert!((pts - 0.0144).abs() < 1e-9, "pts={pts}");
}

#[test]
fn calc_points_currency_conversion() {
// 1000 prompt × 10 CNY/M = 0.01 CNY → 1 点/CNY = 0.01 点
let pts = calc_points(1000.0, 0.0, 0.0, 10.0, 0.0, 0.0, 1, "CNY", "CNY");
let pts = calc_points(1000.0, 0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 1, "CNY", "CNY");
assert!((pts - 0.01).abs() < 1e-9, "pts={pts}");
// USD 价 → CNY 锚定 ×7.2:1000 prompt × 10 USD/M = 0.01 USD = 0.072 CNY
let pts_usd = calc_points(1000.0, 0.0, 0.0, 10.0, 0.0, 0.0, 1, "USD", "CNY");
let pts_usd = calc_points(1000.0, 0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 1, "USD", "CNY");
assert!((pts_usd - 0.072).abs() < 1e-9, "pts_usd={pts_usd}");
}

Expand All @@ -324,14 +345,36 @@ mod tests {
// rant 2026-08-20T10:17:27:输入分「缓存未命中 / 命中」两档价。
// 1000 未命中 × 1.5 CNY/M + 1000 命中 × 0.05 CNY/M + 1000 输出 × 4.5 CNY/M
// = (1500 + 50 + 4500)/1e6 CNY = 0.00605 CNY → 1 点/CNY = 0.00605 点
let pts = calc_points(1000.0, 1000.0, 1000.0, 1.5, 0.05, 4.5, 1, "CNY", "CNY");
let pts = calc_points(
1000.0, 1000.0, 1000.0, 0.0, 1.5, 0.05, 4.5, 0.0, 1, "CNY", "CNY",
);
assert!((pts - 0.00605).abs() < 1e-9, "pts={pts}");
// 命中价缺省 0 → 命中部分不计费(只按未命中 + 输出)
let pts_zero = calc_points(1000.0, 1000.0, 1000.0, 1.5, 0.0, 4.5, 1, "CNY", "CNY");
let pts_zero = calc_points(
1000.0, 1000.0, 1000.0, 0.0, 1.5, 0.0, 4.5, 0.0, 1, "CNY", "CNY",
);
let expect_zero = (1500.0 + 4500.0) / 1e6;
assert!((pts_zero - expect_zero).abs() < 1e-9, "pts_zero={pts_zero}");
}

/// #432:缓存写入是第三档输入价 —— 只有配置了 `cache_write_input_per_m` 才收,
/// 且只按缓存写入的 token 数收,不与未命中/命中重叠。
#[test]
fn calc_points_cache_write_is_a_third_input_price() {
// 1000 写入 × 2.0 CNY/M = 2000/1e6 CNY = 0.002 点(1 点/元);未命中与输出都为 0
let pts = calc_points(0.0, 0.0, 0.0, 1000.0, 1.5, 0.05, 4.5, 2.0, 1, "CNY", "CNY");
assert!((pts - 0.002).abs() < 1e-9, "写入单独计价: {pts}");
// 缺省 0 = 不收费:同样的调用,缓存写入那一段**一分不收**
let pts_free = calc_points(0.0, 0.0, 0.0, 1000.0, 1.5, 0.05, 4.5, 0.0, 1, "CNY", "CNY");
assert_eq!(pts_free, 0.0, "未配置缓存写入价 ⇒ 收 0: {pts_free}");
// 三段互不重叠:未命中 1000 × 1.5 + 命中 1000 × 0.05 + 输出 1000 × 4.5 + 写入 1000 × 2.0
// = (1500 + 50 + 4500 + 2000)/1e6 = 0.00805 CNY
let mixed = calc_points(
1000.0, 1000.0, 1000.0, 1000.0, 1.5, 0.05, 4.5, 2.0, 1, "CNY", "CNY",
);
assert!((mixed - 0.00805).abs() < 1e-9, "四段相加: {mixed}");
}

#[test]
fn is_peak_hour_beijing_boundaries() {
// rant 2026-08-20T11:58:40:高峰 = 北京时 9:00-12:00、14:00-18:00。
Expand Down Expand Up @@ -427,14 +470,28 @@ mod tests {
1_000_000.0,
0.0,
0.0,
0.0,
peak.0,
peak.1,
peak.2,
0.0,
1,
"CNY",
"CNY",
);
let pts_off = calc_points(
1_000_000.0,
0.0,
0.0,
0.0,
off.0,
off.1,
off.2,
0.0,
1,
"CNY",
"CNY",
);
let pts_off = calc_points(1_000_000.0, 0.0, 0.0, off.0, off.1, off.2, 1, "CNY", "CNY");
assert!(
(pts_peak - 3.0).abs() < 1e-9,
"高峰 1M 输入 = 3.0 点: {pts_peak}"
Expand All @@ -449,9 +506,11 @@ mod tests {
1_000_000.0,
0.0,
0.0,
0.0,
no_peak.0,
no_peak.1,
no_peak.2,
0.0,
1,
"CNY",
"CNY",
Expand Down Expand Up @@ -506,6 +565,7 @@ mod tests {

cached_tokens: 30.0,
output_tokens: 20.0,
cache_write_tokens: 0.0,
pts: 2.0,
cost: 0.002,
};
Expand Down Expand Up @@ -639,6 +699,7 @@ mod tests {
tokens: 1_000_000.0,
cached_tokens: 0.0,
output_tokens: 0.0,
cache_write_tokens: 0.0,
pts: 4.5,
cost: 0.0045,
};
Expand Down Expand Up @@ -715,6 +776,7 @@ mod tests {

cached_tokens: 0.0,
output_tokens: 0.0,
cache_write_tokens: 0.0,
pts: 1.0,
cost: 0.001,
};
Expand Down Expand Up @@ -774,6 +836,7 @@ mod tests {

cached_tokens: 0.0,
output_tokens: 0.0,
cache_write_tokens: 0.0,
pts: 3.0,
cost: 0.003,
};
Expand Down Expand Up @@ -844,6 +907,7 @@ mod tests {

cached_tokens: 0.0,
output_tokens: 0.0,
cache_write_tokens: 0.0,
pts: 2.0,
cost: 0.002,
};
Expand Down
14 changes: 10 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,16 @@ pub struct Model {
/// 高峰时段输出价(缺省 0)
#[serde(default)]
pub peak_output_per_m: f64,
/// 缓存写入价(每百万 tokens;缺省 0 = 缓存写入不收费,与今天一致)。
/// ⚠️ rant 2026-10-16T14:08:14 第 2 步只把**分档价**接进了 settle;这一个字段仍是
/// 「只表达能力」—— 上游响应里没有取缓存写入 token 数的通路(`gateway::parse_usage` /
/// `sse::extract_*` 都不产出它),故今天不参与计费。接它需要另加一次用量解析,属后续改动。
/// 缓存写入价(每百万 tokens;缺省 0 = 缓存写入不收费)。
///
/// **参与计费**(#432):`gateway::parse_usage` 与同协议流式的 `UsageCapture` 从
/// anthropic 的 `usage.cache_creation_input_tokens` 取到写入 token 数,
/// `billing::raw_cost` 按本字段计价,结算时落进 `usage_records` / `transactions` 的
/// `cache_write_tokens` 列。缺省 0 ⇒ 收 0 元,与接入前的账逐位一致。
///
/// 三条边界:只有 anthropic 的上游响应带这个字段,其它协议按 0 处理(拿不到就不收,
/// 不估算);`[[models.tiers]]` 不带这一档价,故它与选档无关;`peak_*` 也没有它的
/// 变体,故高峰时段同样不改变它。
#[serde(default)]
pub cache_write_input_per_m: f64,
/// 按**单次请求输入长度**分档的价目(缺省空 = 不启用分档,一律沿用上面的单一价)。
Expand Down
11 changes: 8 additions & 3 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ pub struct ModelPrice {
pub input_per_m: f64,
pub output_per_m: f64,
pub cache_hit_input_per_m: f64,
/// 缓存写入(cache creation)价,缺省 0 = 不收费(#432)。模型级,与分档正交:
/// `ModelTier` 不带这一档价,故选档不影响它。
pub cache_write_input_per_m: f64,
pub peak_input_per_m: f64,
pub peak_output_per_m: f64,
pub peak_cache_hit_input_per_m: f64,
Expand All @@ -201,7 +204,7 @@ pub struct ModelPrice {
/// 高峰价缺省 0 = 不启用高峰计费(billing::effective_prices 处理回落)
pub fn get_model_price(conn: &Connection, provider: &str, model: &str) -> Option<ModelPrice> {
conn.query_row(
"SELECT input_per_m, output_per_m, cache_hit_input_per_m, \
"SELECT input_per_m, output_per_m, cache_hit_input_per_m, cache_write_input_per_m, \
peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, currency, \
tiers_json \
FROM models WHERE provider = ?1 AND model = ?2",
Expand All @@ -214,13 +217,14 @@ pub fn get_model_price(conn: &Connection, provider: &str, model: &str) -> Option
r.get::<_, f64>(3)?,
r.get::<_, f64>(4)?,
r.get::<_, f64>(5)?,
r.get::<_, String>(6)?,
r.get::<_, f64>(6)?,
r.get::<_, String>(7)?,
r.get::<_, String>(8)?,
))
},
)
.ok()
.map(|(i, o, c, pin, pout, pcache, currency, tiers_json)| {
.map(|(i, o, c, cw, pin, pout, pcache, currency, tiers_json)| {
// 解析失败不能装成「无分档」静默少收:留痕后按空档回落(=沿用单一价,与今天一致)。
// 落库的 JSON 由本进程写(db::seed_models),正常路径不会走到这里。
let tiers: Vec<crate::config::ModelTier> = match serde_json::from_str(&tiers_json) {
Expand All @@ -236,6 +240,7 @@ pub fn get_model_price(conn: &Connection, provider: &str, model: &str) -> Option
input_per_m: i,
output_per_m: o,
cache_hit_input_per_m: c,
cache_write_input_per_m: cw,
peak_input_per_m: pin,
peak_output_per_m: pout,
peak_cache_hit_input_per_m: pcache,
Expand Down
Loading
Loading