diff --git a/config/config.example.toml b/config/config.example.toml index b7e854e..ae7b8ab 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -347,6 +347,17 @@ 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 = 缓存写入不收费(与今天一致)。 +# 分档价(rant 2026-10-16T14:08:14 第 1 步:表达能力 + 校验,尚未接进 settle): +# 可选 [[models.tiers]] 数组按**单次请求输入 token 数**分档,from_tokens 是本档下界(含)、 +# 各档 from_tokens 必须 > 0 且严格递增;模型自身的 input_per_m 等是 from_tokens 之前的第一档。例: +# [[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),填错即错账。 # 高峰时段价(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 内) diff --git a/src/config.rs b/src/config.rs index c67a9d6..a575905 100644 --- a/src/config.rs +++ b/src/config.rs @@ -422,6 +422,25 @@ pub struct Endpoint { pub base_url: String, } +/// 单次请求输入长度分档价(rant 2026-10-16T14:08:14 第 1 步)。 +/// +/// 档位按**该次请求的输入 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)] +pub struct ModelTier { + /// 本档起效的输入 token 下界(含)。必须 > 0,且各档严格递增。 + pub from_tokens: i64, + /// 本档缓存未命中输入价(每百万 tokens) + pub input_per_m: f64, + /// 本档缓存命中输入价(每百万 tokens;缺省 0 = 命中免费) + #[serde(default)] + pub cache_hit_input_per_m: f64, + /// 本档输出价(每百万 tokens) + pub output_per_m: f64, +} + /// 模型定义(rant 2026-08-20T10:27:13:config.toml 唯一真源,替代 models.json + price_overrides) #[derive(Debug, Clone, Deserialize)] pub struct Model { @@ -445,6 +464,14 @@ pub struct Model { /// 高峰时段输出价(缺省 0) #[serde(default)] pub peak_output_per_m: f64, + /// 缓存写入价(每百万 tokens;缺省 0 = 缓存写入不收费,与今天一致)。 + /// rant 2026-10-16T14:08:14 第 1 步只落字段与校验,第 2 步才接进 settle 计费。 + #[serde(default)] + pub cache_write_input_per_m: f64, + /// 按**单次请求输入长度**分档的价目(缺省空 = 不启用分档,一律沿用上面的单一价)。 + /// 语义与校验见 [`ModelTier`];rant 2026-10-16T14:08:14 第 1 步只落表达能力与校验。 + #[serde(default)] + pub tiers: Vec, #[serde(default)] pub context_length: i64, #[serde(default)] @@ -498,6 +525,50 @@ impl Config { } } } + // 分档价与缓存写入价的校验(rant 2026-10-16T14:08:14 第 1 步)。档位按 + // from_tokens 递增选择,顺序错或重复会让某档永远选不到/选错档;这些数据填错 + // 就是真金白银错账,故在装载时拒收(第 2 步接进 settle 前先钉死形状)。 + for m in &self.models { + if m.cache_write_input_per_m < 0.0 { + return Err(anyhow!( + "model[{}/{}] 的 cache_write_input_per_m 不能为负(当前 {})", + m.provider, + m.model, + m.cache_write_input_per_m + )); + } + let mut prev: Option = None; + for t in &m.tiers { + if t.from_tokens <= 0 { + return Err(anyhow!( + "model[{}/{}] 的分档 from_tokens 必须 > 0(当前 {})", + m.provider, + m.model, + t.from_tokens + )); + } + if let Some(p) = prev { + if t.from_tokens <= p { + return Err(anyhow!( + "model[{}/{}] 的分档 from_tokens 必须严格递增({} 未大于前一档 {})", + m.provider, + m.model, + t.from_tokens, + p + )); + } + } + prev = Some(t.from_tokens); + if t.input_per_m < 0.0 || t.cache_hit_input_per_m < 0.0 || t.output_per_m < 0.0 { + return Err(anyhow!( + "model[{}/{}] 的分档价不能为负(from_tokens={})", + m.provider, + m.model, + t.from_tokens + )); + } + } + } Ok(()) } } @@ -586,6 +657,15 @@ mod tests { // 未配置高峰价的模型 → 缺省 0(不启用高峰计费) let zhipu = cfg.models.iter().find(|m| m.provider == "zhipu").unwrap(); assert_eq!(zhipu.peak_input_per_m, 0.0, "无高峰价字段 → 缺省 0"); + // 分档价 / 缓存写入价缺省(rant 2026-10-16T14:08:14 第 1 步:两字段均可缺省) + assert_eq!( + zhipu.cache_write_input_per_m, 0.0, + "无 cache_write_input_per_m 字段 → 缺省 0(不收费)" + ); + assert!( + zhipu.tiers.is_empty(), + "无 [[models.tiers]] 段 → tiers 为空(一律沿用单一价)" + ); // server 默认值 assert_eq!(cfg.server.addr, "0.0.0.0:8080"); assert_eq!(cfg.server.db_path, "data/aitokenpool.db"); @@ -758,4 +838,112 @@ mod tests { let err = cfg.validate().unwrap_err().to_string(); assert!(err.contains("非法 protocol"), "err: {err}"); } + + // ---- 分档价 / 缓存写入价(rant 2026-10-16T14:08:14 第 1 步:表达能力 + 校验)---- + + fn tier(from_tokens: i64, input: f64, hit: f64, output: f64) -> ModelTier { + ModelTier { + from_tokens, + input_per_m: input, + cache_hit_input_per_m: hit, + output_per_m: output, + } + } + + /// 把一个分档清单与缓存写入价装到 example 目录的第 0 个模型上,供校验测试用。 + fn cfg_with_prices(tiers: Vec, cache_write: f64) -> Config { + let mut cfg = Config::load("config/config.example.toml").unwrap(); + cfg.models[0].tiers = tiers; + cfg.models[0].cache_write_input_per_m = cache_write; + cfg + } + + #[test] + fn parse_model_tier_from_toml() { + // 表达能力:[[models.tiers]] 的每条按字段进 ModelTier + let t: ModelTier = toml::from_str( + "from_tokens = 512001\ninput_per_m = 4.20\ncache_hit_input_per_m = 0.84\noutput_per_m = 16.80", + ) + .unwrap(); + assert_eq!(t.from_tokens, 512001); + assert_eq!(t.input_per_m, 4.20); + assert_eq!(t.cache_hit_input_per_m, 0.84); + assert_eq!(t.output_per_m, 16.80); + // cache_hit_input_per_m 可缺省(0 = 命中免费) + let no_hit: ModelTier = + toml::from_str("from_tokens = 200000\ninput_per_m = 4.0\noutput_per_m = 12.0").unwrap(); + assert_eq!(no_hit.cache_hit_input_per_m, 0.0); + } + + #[test] + fn model_defaults_to_no_tiers_and_zero_cache_write() { + // 向后兼容(验收 1):example 目录的模型都没写这两项 ⇒ 空 / 0, + // 计价路径读到的仍是原来的单一价,结果与改动前逐字节相同。 + 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 + ); + } + } + + #[test] + fn validate_accepts_strictly_increasing_tiers() { + let cfg = cfg_with_prices( + vec![ + tier(512001, 4.2, 0.84, 16.8), + tier(1_000_001, 8.0, 1.6, 30.0), + ], + 0.5, + ); + cfg.validate().expect("递增且非负的分档价应通过校验"); + } + + #[test] + fn validate_rejects_non_increasing_tier_boundary() { + // 重复边界:同一条 from_tokens 出现两次 ⇒ 后者永远选不到 + let cfg = cfg_with_prices( + vec![tier(512001, 4.2, 0.0, 16.8), tier(512001, 5.0, 0.0, 20.0)], + 0.0, + ); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("严格递增"), "err: {err}"); + // 递减边界 + let cfg = cfg_with_prices( + vec![tier(300000, 1.0, 0.0, 4.0), tier(200000, 2.0, 0.0, 8.0)], + 0.0, + ); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("严格递增"), "err: {err}"); + } + + #[test] + fn validate_rejects_zero_tier_boundary() { + let cfg = cfg_with_prices(vec![tier(0, 4.2, 0.0, 16.8)], 0.0); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("from_tokens 必须 > 0"), "err: {err}"); + } + + #[test] + fn validate_rejects_negative_tier_price() { + let cfg = cfg_with_prices(vec![tier(512001, -4.2, 0.0, 16.8)], 0.0); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("分档价不能为负"), "err: {err}"); + } + + #[test] + fn validate_rejects_negative_cache_write_price() { + let cfg = cfg_with_prices(vec![], -0.1); + let err = cfg.validate().unwrap_err().to_string(); + assert!(err.contains("cache_write_input_per_m"), "err: {err}"); + } }