diff --git a/config/config.example.toml b/config/config.example.toml index ae7b8ab..4dc36e8 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -348,16 +348,26 @@ endpoints = [ # 单位:每百万 tokens(currency 对应币种);input_per_m = 缓存未命中输入价, # cache_hit_input_per_m = 缓存命中输入价(缺省 0 = 命中免费)。 # 缓存写入价(rant 2026-10-16T14:08:14):可选 cache_write_input_per_m(每百万 tokens), -# 缺省 0 = 缓存写入不收费(与今天一致)。 -# 分档价(rant 2026-10-16T14:08:14 第 1 步:表达能力 + 校验,尚未接进 settle): -# 可选 [[models.tiers]] 数组按**单次请求输入 token 数**分档,from_tokens 是本档下界(含)、 -# 各档 from_tokens 必须 > 0 且严格递增;模型自身的 input_per_m 等是 from_tokens 之前的第一档。例: +# 缺省 0 = 缓存写入不收费(与今天一致)。⚠️ 这个字段目前**只表达、不收费**:上游响应里 +# 没有取「缓存写入 token 数」的通路(gateway parse_usage / sse 都不产出它),接进计费要 +# 另加一次用量解析,属后续改动。 +# 分档价(rant 2026-10-16T14:08:14 第 2 步:settle 已按请求输入 token 数选档): +# 可选 [[models.tiers]] 数组按**单次请求输入 token 数**分档 —— 输入 = 缓存未命中 + 缓存 +# 命中那一段 prompt(不是用户累计用量,也不是输出)。from_tokens 是本档下界(含),各档 +# from_tokens 必须 > 0 且严格递增;模型自身的 input_per_m 等是 from_tokens 之前的第一档。 +# 官方写法若是开区间(如 MiniMax 的「输入 >512k」),把边界 +1 写进 from_tokens(512001)。 +# ⚠️ [[models.tiers]] 必须写在所属 [[models]] 的**最后**(TOML:子表头之后不能再给父表加键)。 +# 例: # [[models.tiers]] # from_tokens = 512001 # input_per_m = 4.20 # cache_hit_input_per_m = 0.84 # output_per_m = 16.80 # ⛔ 档位数据只填官网确实给了的:本仓 config 是唯一真源(src/db.rs::seed_models),填错即错账。 +# ⚠️ 已填长档的(官网核实,见各行注释):minimax-m3(>512k)、gemini-3.1-pro、grok-4.6、 +# grok-4.7(后三家都是 >200k)。未填的一律是「官网**没给这个数**」,不是漏了: +# qwen3.7-flash(分档只公布输入/输出两列、缓存命中价未公布 ⇒ 补档会把命中价打成 0 少收)、 +# OpenAI 各家(分了短/长两段,但**没公布分界 token 数**)、Claude(价格页无长档一段)—— 待补。 # 高峰时段价(rant 2026-08-20T11:58:40):peak_input_per_m / peak_output_per_m / # peak_cache_hit_input_per_m 三个可选字段,缺省 0 = 不启用高峰计费(沿用空闲价)。 # 命中高峰时段(北京时间 9-12、14-18,**周一至周五**且不在 [billing] holidays 内) @@ -502,6 +512,8 @@ vision = false [[models]] provider = "google" model = "gemini-3.1-pro" +# 官网价(2026-10-16 核实,https://ai.google.dev/gemini-api/docs/pricing):≤200k +# $2.00 / 缓存命中 $0.20 / $12.00;**>200k** 为 $4.00 / $0.40 / $18.00(rant 2026-10-16T14:08:14)。 currency = "USD" input_per_m = 2.0 cache_hit_input_per_m = 0.2 @@ -510,6 +522,12 @@ context_length = 1048576 max_output = 0 vision = false +[[models.tiers]] +from_tokens = 200001 +input_per_m = 4.00 +cache_hit_input_per_m = 0.40 +output_per_m = 18.00 + # —— 新一代 Gemini(2026-10-16 核实,https://ai.google.dev/gemini-api/docs/pricing)—— # ⚠️ 两条都带限时促销价:本仓没有「按时切换价格」的字段 ⇒ 记现值,并在注释里写明两个数与切换日 # (rant 2026-10-16T15:20:36.482297 第 6 条)。 @@ -551,12 +569,20 @@ output_per_m = 6.0 context_length = 500000 max_output = 0 vision = false +# 长档(2026-10-17 官网核实,https://docs.x.ai/developers/pricing):表头写「Long context +# ≥ 200k tokens」,正文写「long-context rates apply once a request's prompt **exceeds** +# 200k tokens」⇒ 开区间,下界写 200k+1(rant 2026-10-16T14:08:14 第 2 步)。 +[[models.tiers]] +from_tokens = 200001 +input_per_m = 4.00 +cache_hit_input_per_m = 1.00 +output_per_m = 12.00 [[models]] provider = "xai" model = "grok-4.7" # 官网现役旗舰(2026-10-16 核实,https://docs.x.ai/developers/pricing):$2.00 / 缓存命中 $0.50 / -# $6.00,500k 上下文;≥200k 长档为 $4.00 / $1.00 / $12.00(本仓价格字段不分档) +# $6.00,500k 上下文;长档为 $4.00 / $1.00 / $12.00(见下方 [[models.tiers]]) currency = "USD" input_per_m = 2.0 cache_hit_input_per_m = 0.5 @@ -564,6 +590,12 @@ output_per_m = 6.0 context_length = 500000 max_output = 0 vision = false +# 长档(2026-10-17 官网核实,同上):与 grok-4.6 同价同界 —— 开区间 >200k ⇒ 下界 200k+1。 +[[models.tiers]] +from_tokens = 200001 +input_per_m = 4.00 +cache_hit_input_per_m = 1.00 +output_per_m = 12.00 [[models]] provider = "moonshot" @@ -589,6 +621,9 @@ vision = false [[models]] provider = "minimax" model = "minimax-m3" +# 官网价(2026-10-16 核实,https://platform.minimaxi.com/document/price):≤512k 永久五折 +# 2.10 / 缓存读取 0.42 / 8.40 元每百万 tokens;**输入 >512k** 为 4.20 / 0.84 / 16.80 +# ⇒ 长档按官网的开区间写法把下界 +1(512001)(rant 2026-10-16T14:08:14)。 currency = "CNY" input_per_m = 2.1 cache_hit_input_per_m = 0.42 @@ -597,6 +632,12 @@ context_length = 1048576 max_output = 0 vision = false +[[models.tiers]] +from_tokens = 512001 +input_per_m = 4.20 +cache_hit_input_per_m = 0.84 +output_per_m = 16.80 + [[models]] provider = "minimax" model = "minimax-m2.7" diff --git a/docs/architecture.md b/docs/architecture.md index 85f4483..076ba93 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ | `users` | 用户(email / password_hash / name / role / verified) | | `keys` | 上游 key(provider / plan / model / 加密密文 / 额度 / 可用时间段 / note) | | `api_keys` | 分发 key(`atk_live_` 前缀,绑定用户,可撤销) | -| `models` | 模型价格(input / output / cache_hit,可选高峰价;config `[[models]]` 为唯一真源) | +| `models` | 模型价格(input / output / cache_hit,可选高峰价与按单次请求输入长度分档的价目 `tiers_json`;config `[[models]]` 为唯一真源) | | `quotas` | 点数账户(balance 永久 + gift_balance 有效赠送) | | `gift_grants` | 赠送明细(amount / expires_at / status: active\|used\|expired) | | `transactions` | 交易流水(type 取值以 `src/routes/wallet.rs` 的 `TX_FILTER_TYPES` 为准;含 token 明细列) | diff --git a/src/billing.rs b/src/billing.rs index 561b30e..b2d78c2 100644 --- a/src/billing.rs +++ b/src/billing.rs @@ -131,6 +131,34 @@ pub fn effective_prices( ) } +/// 按**该次请求的输入 token 数**选生效档(rant 2026-10-16T14:08:14 第 2 步)。 +/// +/// `tiers` 来自 config `[[models.tiers]]`(`Config::validate` 已保证各档 `from_tokens > 0` +/// 且**严格递增**),每个 `from_tokens` 是**含**下界 —— 官方写法 `[from_tokens, 下一档)`; +/// 官方写法若是开区间(MiniMax 的「输入 >512k」),由数据补 1 表达。 +/// +/// 返回命中档的 `(缓存未命中输入价, 缓存命中输入价, 输出价)`。`input_tokens` 传该次请求的 +/// **全部输入**(未命中 + 命中)—— 官方长上下文档说的就是整段 prompt 的长度。 +/// +/// 返回 `None` 的两种情形都表示「沿用 `Model` 自身的单一价」:`tiers` 为空(不启用分档), +/// 或输入还没到第一档的 `from_tokens`(`Model` 自身那三个价就是第一档)。这正是 +/// 「没有 tiers 的模型计价逐字节不变」所走的那条路。 +pub fn select_tier( + input_tokens: f64, + tiers: &[crate::config::ModelTier], +) -> Option<(f64, f64, f64)> { + let mut hit = None; + for t in tiers { + if (t.from_tokens as f64) <= input_tokens { + hit = Some((t.input_per_m, t.cache_hit_input_per_m, t.output_per_m)); + } else { + // 严格递增 ⇒ 后面的档只会更大,不必再看 + break; + } + } + hit +} + /// 入账参数 pub struct SettleParams { /// 消费者(调用者)user_id @@ -850,4 +878,54 @@ mod tests { drop(conn); let _ = std::fs::remove_file(p); } + + // ── select_tier:按**该次请求的输入 token 数**选档(rant 2026-10-16T14:08:14 第 2 步)── + + fn t(from: i64, input: f64, hit: f64, output: f64) -> crate::config::ModelTier { + crate::config::ModelTier { + from_tokens: from, + input_per_m: input, + cache_hit_input_per_m: hit, + output_per_m: output, + } + } + + #[test] + fn select_tier_is_none_without_tiers() { + // 没有 [[models.tiers]] 的模型(今天 24 行里 20 行)走这条路 ⇒ 沿用单一价 + assert!(select_tier(1_000_000.0, &[]).is_none()); + } + + #[test] + fn select_tier_below_the_first_boundary_keeps_the_single_price() { + let tiers = vec![t(200_001, 4.0, 1.0, 12.0)]; + assert!( + select_tier(200_000.0, &tiers).is_none(), + "未到第一档下界 ⇒ None(=沿用模型自身的第一档价)" + ); + assert!(select_tier(0.0, &tiers).is_none(), "零输入 ⇒ 第一档"); + } + + #[test] + fn select_tier_boundary_is_an_inclusive_lower_bound() { + let tiers = vec![t(200_001, 4.0, 1.0, 12.0), t(512_001, 8.0, 2.0, 24.0)]; + // 每条边界的 from_tokens−1 / from_tokens / from_tokens+1 三腿 + assert!(select_tier(200_000.0, &tiers).is_none()); + assert_eq!(select_tier(200_001.0, &tiers), Some((4.0, 1.0, 12.0))); + assert_eq!(select_tier(200_002.0, &tiers), Some((4.0, 1.0, 12.0))); + assert_eq!(select_tier(512_000.0, &tiers), Some((4.0, 1.0, 12.0))); + assert_eq!(select_tier(512_001.0, &tiers), Some((8.0, 2.0, 24.0))); + assert_eq!( + select_tier(9_999_999.0, &tiers), + Some((8.0, 2.0, 24.0)), + "超过末档 ⇒ 末档(不是 None)" + ); + } + + #[test] + fn select_tier_reads_all_three_prices_from_the_hit_tier() { + // 命中价也随档走:第二档命中价 1.0(不是第一档的 0.5) + let tiers = vec![t(200_001, 4.0, 1.0, 12.0)]; + assert_eq!(select_tier(300_000.0, &tiers), Some((4.0, 1.0, 12.0))); + } } diff --git a/src/config.rs b/src/config.rs index a575905..dbe5f91 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,7 @@ //! //! P0-A(rant 2026-08-17T22:21:52):服务骨架 + 配置加载 -use serde::Deserialize; +use serde::{Deserialize, Serialize}; fn default_addr() -> String { "0.0.0.0:8080".to_string() @@ -422,13 +422,19 @@ pub struct Endpoint { pub base_url: String, } -/// 单次请求输入长度分档价(rant 2026-10-16T14:08:14 第 1 步)。 +/// 单次请求输入长度分档价(rant 2026-10-16T14:08:14;第 1 步落表达能力,第 2 步接进 settle)。 /// /// 档位按**该次请求的输入 token 数**选择(不是用户累计用量):`Model` 自身的 /// `input_per_m` / `cache_hit_input_per_m` / `output_per_m` 是**第一档**, /// `Model::tiers` 里每一条是 `from_tokens` 起(**含**)生效的档;`from_tokens` 是各档的 /// **下界**(官方写法 `[from_tokens, 下一档)`)。 -#[derive(Debug, Clone, Deserialize)] +/// +/// 「该次请求的输入 token 数」= 该次请求的**全部输入**,即缓存未命中输入 + 缓存命中输入 +/// (`billing::select_tier` 的入参)。官方文档里的长上下文档说的就是这一整段 prompt 的长度。 +/// +/// 官方写法若不是左闭区间(MiniMax 的「输入 >512k」是开区间),由**数据**补 1 表达 +/// (`from_tokens = 512001`),机制本身始终是 `[from_tokens, 下一档)`。 +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ModelTier { /// 本档起效的输入 token 下界(含)。必须 > 0,且各档严格递增。 pub from_tokens: i64, @@ -465,11 +471,14 @@ pub struct Model { #[serde(default)] pub peak_output_per_m: f64, /// 缓存写入价(每百万 tokens;缺省 0 = 缓存写入不收费,与今天一致)。 - /// rant 2026-10-16T14:08:14 第 1 步只落字段与校验,第 2 步才接进 settle 计费。 + /// ⚠️ rant 2026-10-16T14:08:14 第 2 步只把**分档价**接进了 settle;这一个字段仍是 + /// 「只表达能力」—— 上游响应里没有取缓存写入 token 数的通路(`gateway::parse_usage` / + /// `sse::extract_*` 都不产出它),故今天不参与计费。接它需要另加一次用量解析,属后续改动。 #[serde(default)] pub cache_write_input_per_m: f64, /// 按**单次请求输入长度**分档的价目(缺省空 = 不启用分档,一律沿用上面的单一价)。 - /// 语义与校验见 [`ModelTier`];rant 2026-10-16T14:08:14 第 1 步只落表达能力与校验。 + /// 语义与校验见 [`ModelTier`];由 `billing::select_tier` 在 settle 时按该次请求的输入 + /// token 数选档(rant 2026-10-16T14:08:14 第 2 步)。 #[serde(default)] pub tiers: Vec, #[serde(default)] @@ -525,9 +534,9 @@ impl Config { } } } - // 分档价与缓存写入价的校验(rant 2026-10-16T14:08:14 第 1 步)。档位按 + // 分档价与缓存写入价的校验(rant 2026-10-16T14:08:14)。档位按 // from_tokens 递增选择,顺序错或重复会让某档永远选不到/选错档;这些数据填错 - // 就是真金白银错账,故在装载时拒收(第 2 步接进 settle 前先钉死形状)。 + // 就是真金白银错账,故在装载时拒收。 for m in &self.models { if m.cache_write_input_per_m < 0.0 { return Err(anyhow!( @@ -876,23 +885,52 @@ mod tests { } #[test] - fn model_defaults_to_no_tiers_and_zero_cache_write() { - // 向后兼容(验收 1):example 目录的模型都没写这两项 ⇒ 空 / 0, - // 计价路径读到的仍是原来的单一价,结果与改动前逐字节相同。 + fn model_without_a_tiers_section_defaults_to_no_tiers_and_zero_cache_write() { + // 向后兼容:没写 [[models.tiers]] 的模型 ⇒ tiers 为空、缓存写入价 0, + // 计价路径读到的仍是模型自身的单一价,结果与改动前逐字节相同。 + let cfg = Config::load("config/config.example.toml").unwrap(); + let m = cfg + .models + .iter() + .find(|m| m.provider == "deepseek" && m.model == "deepseek-flash") + .expect("example 配置应有 deepseek-flash(不写分档)"); + assert!(m.tiers.is_empty(), "未配置分档 ⇒ tiers 应为空"); + assert_eq!(m.cache_write_input_per_m, 0.0, "未配置缓存写入价 ⇒ 缺省 0"); + } + + #[test] + fn example_config_declares_the_verified_long_context_tiers() { + // 第 2 步的数据面:官网给了长档的模型带 [[models.tiers]],边界按官方「>N」 + // (开区间)写法 +1 表达。这四行是核价记录里**确有数**的全部;其余模型没写 + // 是因为官网没给这个数(见 §4 头注释),不是漏了。 let cfg = Config::load("config/config.example.toml").unwrap(); - assert!(!cfg.models.is_empty()); - for m in &cfg.models { - assert!( - m.tiers.is_empty(), - "{}/{} 未配置分档 ⇒ tiers 应为空", - m.provider, - m.model - ); - assert_eq!( - m.cache_write_input_per_m, 0.0, - "{}/{} 未配置缓存写入价 ⇒ 缺省 0", - m.provider, m.model - ); + let tier_of = |model: &str| { + cfg.models + .iter() + .find(|m| m.model == model) + .unwrap_or_else(|| panic!("example 配置应有 {model}")) + .tiers + .clone() + }; + // MiniMax-M3:官网「输入 >512k」4.20 / 0.84 / 16.80 元 + let m3 = tier_of("minimax-m3"); + assert_eq!(m3.len(), 1); + assert_eq!(m3[0].from_tokens, 512_001); + assert_eq!(m3[0].input_per_m, 4.20); + assert_eq!(m3[0].cache_hit_input_per_m, 0.84); + assert_eq!(m3[0].output_per_m, 16.80); + // Gemini 3.1 Pro / Grok 4.6 / Grok 4.7:>200k 长档 + for (model, input, hit, output) in [ + ("gemini-3.1-pro", 4.00, 0.40, 18.00), + ("grok-4.6", 4.00, 1.00, 12.00), + ("grok-4.7", 4.00, 1.00, 12.00), + ] { + let t = tier_of(model); + assert_eq!(t.len(), 1, "{model} 应恰有一条长档"); + assert_eq!(t[0].from_tokens, 200_001, "{model} 长档下界"); + assert_eq!(t[0].input_per_m, input, "{model} 长档输入价"); + assert_eq!(t[0].cache_hit_input_per_m, hit, "{model} 长档命中价"); + assert_eq!(t[0].output_per_m, output, "{model} 长档输出价"); } } diff --git a/src/dao.rs b/src/dao.rs index 571fc10..9b1b4d9 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -179,32 +179,70 @@ pub fn get_available_balance(conn: &Connection, user_id: i64) -> f64 { permanent + gift } -/// 模型单价(按 provider+model)→ (input_per_m, output_per_m, cache_hit_input_per_m, -/// peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, currency) +/// 一个模型的价目(`get_model_price` 的返回值)。 +/// +/// `tiers` 是 config `[[models.tiers]]` 的落库副本(`models.tiers_json`,rant +/// 2026-10-16T14:08:14 第 2 步);`Config::validate` 保证 `from_tokens` 严格递增,故这里 +/// 不再排序。空 = 不启用分档,settle 沿用 `input_per_m` / `cache_hit_input_per_m` / +/// `output_per_m` 这一组第一档价。高峰价与分档价正交:`peak_*` 是**模型级**的, +/// 命中高峰时在**所选档**的基价上回落(见 `billing::effective_prices`)。 +pub struct ModelPrice { + pub input_per_m: f64, + pub output_per_m: f64, + pub cache_hit_input_per_m: f64, + pub peak_input_per_m: f64, + pub peak_output_per_m: f64, + pub peak_cache_hit_input_per_m: f64, + pub currency: String, + pub tiers: Vec, +} + +/// 模型单价(按 provider+model)→ [`ModelPrice`](含分档价,rant 2026-10-16T14:08:14 第 2 步) /// 高峰价缺省 0 = 不启用高峰计费(billing::effective_prices 处理回落) -pub fn get_model_price( - conn: &Connection, - provider: &str, - model: &str, -) -> Option<(f64, f64, f64, f64, f64, f64, String)> { +pub fn get_model_price(conn: &Connection, provider: &str, model: &str) -> Option { conn.query_row( "SELECT input_per_m, output_per_m, cache_hit_input_per_m, \ - peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, currency \ + 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", rusqlite::params![provider, model], |r| { Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get(5)?, - r.get(6)?, + r.get::<_, f64>(0)?, + r.get::<_, f64>(1)?, + r.get::<_, f64>(2)?, + r.get::<_, f64>(3)?, + r.get::<_, f64>(4)?, + r.get::<_, f64>(5)?, + r.get::<_, String>(6)?, + r.get::<_, String>(7)?, )) }, ) .ok() + .map(|(i, o, c, pin, pout, pcache, currency, tiers_json)| { + // 解析失败不能装成「无分档」静默少收:留痕后按空档回落(=沿用单一价,与今天一致)。 + // 落库的 JSON 由本进程写(db::seed_models),正常路径不会走到这里。 + let tiers: Vec = match serde_json::from_str(&tiers_json) { + Ok(t) => t, + Err(e) => { + log::error!( + "models.tiers_json 解析失败(provider={provider} model={model})⇒ 本次按单一价计费: {e}" + ); + Vec::new() + } + }; + ModelPrice { + input_per_m: i, + output_per_m: o, + cache_hit_input_per_m: c, + peak_input_per_m: pin, + peak_output_per_m: pout, + peak_cache_hit_input_per_m: pcache, + currency, + tiers, + } + }) } /// 市场页:models 表 + key 可用性(可用 key 数) @@ -553,4 +591,63 @@ mod tests { drop(conn); let _ = std::fs::remove_file(p); } + + // ── models.tiers_json(rant 2026-10-16T14:08:14 第 2 步)── + + fn insert_model(conn: &Connection, model: &str, tiers_json: &str) { + conn.execute( + "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, \ + cache_hit_input_per_m, tiers_json) \ + VALUES ('xai', ?1, 'USD', 2.0, 6.0, 0.5, ?2)", + rusqlite::params![model, tiers_json], + ) + .unwrap(); + } + + #[test] + fn get_model_price_round_trips_tiers_json() { + let (conn, p) = tmp_conn("tiers"); + insert_model( + &conn, + "grok-4.6", + r#"[{"from_tokens":200001,"input_per_m":4.0,"cache_hit_input_per_m":1.0,"output_per_m":12.0}]"#, + ); + let price = get_model_price(&conn, "xai", "grok-4.6").expect("取到价"); + assert_eq!(price.tiers.len(), 1); + assert_eq!(price.tiers[0].from_tokens, 200_001); + assert_eq!(price.tiers[0].input_per_m, 4.0); + assert_eq!(price.tiers[0].cache_hit_input_per_m, 1.0); + assert_eq!(price.tiers[0].output_per_m, 12.0); + + // `cache_hit_input_per_m` 可缺省(`#[serde(default)]` ⇒ 0 = 命中免费) + insert_model( + &conn, + "no-hit", + r#"[{"from_tokens":512001,"input_per_m":4.2,"output_per_m":16.8}]"#, + ); + let price = get_model_price(&conn, "xai", "no-hit").unwrap(); + assert_eq!(price.tiers[0].cache_hit_input_per_m, 0.0); + + // 无档位 ⇒ 空(=沿用单一价),不是「查不到」 + insert_model(&conn, "none", "[]"); + let price = get_model_price(&conn, "xai", "none").expect("无分档也取得到价"); + assert!(price.tiers.is_empty()); + + drop(conn); + let _ = std::fs::remove_file(p); + } + + #[test] + fn get_model_price_falls_back_to_the_single_price_on_malformed_tiers_json() { + // 坏 JSON 不能装成「无分档」静默少收:回落单一价(并有一条 error 日志,见函数体)。 + // 落库的 JSON 由本进程写,正常路径到不了这里 —— 这条腿钉住的是兜底形态本身。 + let (conn, p) = tmp_conn("tiers_bad"); + insert_model(&conn, "broken", "not json"); + let price = get_model_price(&conn, "xai", "broken").expect("坏 JSON 仍取得到基价"); + assert!(price.tiers.is_empty(), "坏 JSON ⇒ 空档"); + assert_eq!(price.input_per_m, 2.0, "基价不受影响"); + + drop(conn); + let _ = std::fs::remove_file(p); + } } diff --git a/src/db.rs b/src/db.rs index b9dfa41..2fcb7aa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; -pub const SCHEMA_VERSION: i64 = 16; +pub const SCHEMA_VERSION: i64 = 17; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -359,6 +359,17 @@ pub fn migrate(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_transactions_key_id_type_pts ON transactions(key_id, type, pts);", )?; + // v17(rant 2026-10-16T14:08:14 第 2 步):分档价随模型行落库。档位是**变长**的一组 + // 结构(config `[[models.tiers]]`),故存成模型行自己的一列 JSON 文本,而不是另立 + // `model_tiers` 表:后者要多管一份生命周期(seed 的 stale 删除、admin 的 remove/patch + // 都得跟着级联,漏一处就留下指向已删模型的孤儿档),而列与行同生共死、读侧也只多取 + // 一列(`dao::get_model_price` 在同一个查询里取回)。空数组 `[]` = 不启用分档。 + ensure_column( + conn, + "models", + "tiers_json", + "tiers_json TEXT NOT NULL DEFAULT '[]'", + )?; // v16(rant 2026-10-09T12:28:58 验收项 1):交易明细的**可加汇总表** —— 维度 = 类型 + 用户 + // 模型 + key(两个身份都进维度,见 `tx_rollup` 模块头)+ 状态 + 时间(分钟级);可加量 = // `pts` 与各 token 的 `SUM` + 行数 `COUNT`。折叠逻辑在 `src/tx_rollup.rs`;本步只建表。 @@ -614,9 +625,13 @@ pub(crate) fn seed_test_users(conn: &Connection) -> Result<()> { pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> { let mut n = 0u32; for m in &cfg.models { + // 分档价随行落库(v17,rant 2026-10-16T14:08:14 第 2 步):序列化成 JSON 文本。 + // `Config::validate` 已保证各档 from_tokens > 0 且严格递增 ⇒ 读侧不必再排序。 + let tiers_json = serde_json::to_string(&m.tiers) + .with_context(|| format!("序列化 model[{}/{}] 的分档价失败", m.provider, m.model))?; conn.execute( - "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, context_window, context_length, max_output, vision, cache_hit_input_per_m, peak_input_per_m, peak_cache_hit_input_per_m, peak_output_per_m, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now')) \ + "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, context_window, context_length, max_output, vision, cache_hit_input_per_m, peak_input_per_m, peak_cache_hit_input_per_m, peak_output_per_m, tiers_json, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now')) \ ON CONFLICT(provider, model) DO UPDATE SET \ currency = excluded.currency, \ input_per_m = excluded.input_per_m, \ @@ -629,6 +644,7 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> peak_input_per_m = excluded.peak_input_per_m, \ peak_cache_hit_input_per_m = excluded.peak_cache_hit_input_per_m, \ peak_output_per_m = excluded.peak_output_per_m, \ + tiers_json = excluded.tiers_json, \ updated_at = datetime('now')", rusqlite::params![ m.provider, @@ -644,6 +660,7 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> m.peak_input_per_m, m.peak_cache_hit_input_per_m, m.peak_output_per_m, + tiers_json, ], )?; n += 1; diff --git a/src/gateway.rs b/src/gateway.rs index abe2aa9..691c890 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -181,20 +181,27 @@ fn settle_usage( }; let price = dao::get_model_price(&conn, &key.provider, model); let (pts, cost) = match price { - Some((i_per_m, o_per_m, cache_hit_per_m, p_in, p_out, p_cache, currency)) => { + Some(p) => { // rant 2026-08-20T11:58:40:DeepSeek 高峰时段(北京 9-12/14-18)价格翻倍—— // 按当前北京时间选高峰/空闲价;未配置高峰价的模型不受影响。 // rant 2026-10-16T14:08:13:高峰只落在**周一至周五**,且不含 [billing] holidays // 列出的法定节假日 —— 周末与节假日全天按空闲价。 + // rant 2026-10-16T14:08:14 第 2 步:长输入先按**该次请求的输入 token 数**选档 —— + // 整段 prompt = 缓存未命中输入 + 缓存命中输入(不是用户累计用量,也不是输出)。 + // 没有分档、或输入还没到第一档 `from_tokens` ⇒ 沿用模型自身的单一价(第一档)。 + let input_total = input_tokens + cached_tokens; + let (i_per_m, cache_hit_per_m, o_per_m) = billing::select_tier(input_total, &p.tiers) + .unwrap_or((p.input_per_m, p.cache_hit_input_per_m, p.output_per_m)); let peak = billing::is_peak_hour(&chrono::Utc::now(), &st.cfg.billing.holidays); + // 高分档与高峰正交:`peak_*` 是模型级的,在**所选档**的基价上回落 let (i2, c2, o2) = billing::effective_prices( peak, i_per_m, cache_hit_per_m, o_per_m, - p_in, - p_cache, - p_out, + p.peak_input_per_m, + p.peak_cache_hit_input_per_m, + p.peak_output_per_m, ); let pts = billing::calc_points( input_tokens, @@ -204,12 +211,12 @@ fn settle_usage( c2, o2, st.cfg.points.points_per_unit, - ¤cy, + &p.currency, &st.cfg.points.anchor_currency, ); let cost = billing::to_anchor( billing::raw_cost(input_tokens, cached_tokens, output_tokens, i2, c2, o2), - ¤cy, + &p.currency, &st.cfg.points.anchor_currency, ); (pts, cost) @@ -1086,6 +1093,77 @@ mod tests { } /// 测试状态(P3-A):可指定 plan 只暴露部分协议端点(验证协议转换路径) + /// rant 2026-10-16T14:08:14 第 2 步:settle 真的按**该次请求的输入 token 数**选档。 + /// + /// 走**完整**入账路径(`seed_models` 落库 → `dao` 取价 → `select_tier` 选档 → 入账), + /// 读回 `transactions.pts` 断言钱数在边界两侧翻档。用 example 里 grok-4.6 的真实档位: + /// 第一档 $2 / 长档 $4(>200k);该行无高峰价 ⇒ 与运行时刻无关(高峰那半由 billing 测试管)。 + #[test] + fn settle_picks_the_tier_by_this_requests_input_tokens() { + let st = test_state_eps( + "tier_settle", + "xai-paygo", + &["openai_chat"], + "http://127.0.0.1:1", + ); + { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO keys (id, provider, plan, model, status, owner_id, encrypted_key, quota, used) \ + VALUES (900, 'xai', 'xai-paygo', 'grok-4.6', 'on', 1, 'v1:x', 1000, 0)", + [], + ) + .unwrap(); + } + let key = dao::KeyRow { + id: 900, + provider: "xai".to_string(), + plan: "xai-paygo".to_string(), + owner_id: 1, + encrypted_key: String::new(), + }; + let auth = || AuthUser { + user_id: 1, + api_key_id: 1, + role: "user".to_string(), + }; + + // ① 200000(边界−1)⇒ 第一档 $2/M;② 200001(边界)⇒ 长档 $4/M(恰 2 倍) + settle_usage(&st, auth(), &key, "grok-4.6", 200_000.0, 0.0, 0.0); + settle_usage(&st, auth(), &key, "grok-4.6", 200_001.0, 0.0, 0.0); + // ③ 选档用的是**整段 prompt**:未命中 100000 + 命中 100001 = 200001 ⇒ 仍走长档 + // (若错看未命中那一段,就会落回第一档) + settle_usage(&st, auth(), &key, "grok-4.6", 100_000.0, 100_001.0, 0.0); + + let pts: Vec = { + let conn = st.db.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT pts FROM transactions WHERE type = 'consume' ORDER BY id ASC") + .unwrap(); + let v: Vec = stmt + .query_map([], |r| r.get(0)) + .unwrap() + .map(|x| x.unwrap()) + .collect(); + v + }; + assert_eq!(pts.len(), 3, "三笔调用各一条 consume"); + // 200000 × $2/M = 0.4 USD × 7.2 = 2.88 点 + assert!((pts[0] - 2.88).abs() < 1e-6, "边界前应走第一档: {}", pts[0]); + // 200001 × $4/M = 0.800004 USD × 7.2 = 5.76003 点 + assert!( + (pts[1] - 5.76003).abs() < 1e-6, + "跨过 200k 应走长档($4/M): {}", + pts[1] + ); + // 100000 × $4/M + 100001 × $1/M = 0.500001 USD × 7.2 = 3.60001 点 + assert!( + (pts[2] - 3.60001).abs() < 1e-6, + "选档口径 = 未命中 + 命中(整段 prompt): {}", + pts[2] + ); + } + fn test_state_eps(tag: &str, plan_id: &str, protocols: &[&str], base_url: &str) -> AppState { let p = std::env::temp_dir().join(format!("atp_gw_{}_{}.db", std::process::id(), tag)); let _ = std::fs::remove_file(&p); diff --git a/src/routes/admin_models.rs b/src/routes/admin_models.rs index 0b6cf01..861b890 100644 --- a/src/routes/admin_models.rs +++ b/src/routes/admin_models.rs @@ -322,11 +322,15 @@ pub async fn patch( Json(serde_json::json!({ "error": "模型已存在(provider+model 唯一)" })), )); } + // 分档价(`tiers_json`)是 **config 专有**字段:管理接口不表达它,且改价后旧档位价 + // 与新基价不再自洽(长输入会按旧档位收)⇒ 手工编辑一律清空,宁可退回单一价。 + // 模型表是 config 权威表(`db::seed_models` 每次启动全量 upsert),下次启动即恢复。 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, updated_at = datetime('now') \ + peak_cache_hit_input_per_m = ?12, context_window = ?6, tiers_json = '[]', \ + updated_at = datetime('now') \ WHERE id = ?13", params![ provider,