From a20eda40ae5c69c74e94ab5d774d6183135904ab Mon Sep 17 00:00:00 2001 From: argszero Date: Thu, 20 Aug 2026 12:36:52 +0800 Subject: [PATCH] feat(billing): DeepSeek peak-hour pricing (rant 2026-08-20T11:58:40) Peak hours (Beijing 9:00-12:00 / 14:00-18:00, Mon-Sun) bill at 2x the off-peak rate for DeepSeek. Config models gain optional peak_* price fields (default 0 = peak billing off), models table migration v9, billing picks prices by fixed Asia/Shanghai time, market UI shows a 'peak xN' badge + detail row, admin model form supports peak prices. --- README.md | 5 +- config/config.example.toml | 14 +++- docs/architecture.md | 3 +- src/billing.rs | 157 +++++++++++++++++++++++++++++++++++++ src/config.rs | 19 +++++ src/dao.rs | 36 +++++++-- src/db.rs | 93 +++++++++++++++++++--- src/gateway.rs | 29 ++++--- src/routes/admin_models.rs | 95 +++++++++++++++++++--- ui/index.html | 11 +++ ui/js/app.js | 24 +++++- ui/js/data.js | 2 +- ui/js/i18n.js | 20 +++++ 13 files changed, 455 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 5a72343..1c106ed 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke ## 状态 -后端 + 前端已全部实现,当前版本 **v0.7.2**(config.toml 模型目录唯一真源): +后端 + 前端已全部实现,当前版本 **v0.7.3**(config.toml 模型目录唯一真源): - ✅ **P0-A(v0.2.0)**:后端骨架 + TOML 配置(`Config::validate`:points_per_unit>0 / plan→provider 存在 / endpoints≥1 / protocol 枚举)+ SQLite 数据层(幂等迁移,**生产空库不种任何种子数据**)+ 认证(argon2 + Bearer API Key)+ API Key 端点 - ✅ **P0-B(v0.2.1)**:网关转发(OpenAI Chat Completions + Anthropic Messages)+ 路由故障转移(粘性 / 健康冷却 / 3 次切换上限)+ 计量账本(点数计算、90/10 分成、事务性 settle) @@ -40,6 +40,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke - ✅ **v0.6.6**:**统一数据目录(rant 2026-08-19T20:53:23)**——`ATP_DATA_DIR`(默认 `./data`;`--data-dir` > env > 默认)下放 config.toml(首次自动复制示例)+ aitokenpool.db + logs/,目录自动创建;数据库路径统一由 data-dir 决定(config `db_path` 忽略);Docker 单卷挂载 `./atp-data:/data`(镜像内置示例配置,首启自动复制);.gitignore/.dockerignore 补 atp-data/;旧 data/ 迁移说明 - ✅ **v0.6.7**:**日志系统(rant 2026-08-19T20:54:26)**——log4rs 替换 env_logger:日志落盘 `/logs/aitokenpool.log` + stdout 双写;按大小滚动(`[log].max_file_size`,默认 10MB)+ `max_backups`(默认 7)自动清理旧日志;`[log]` 配置段(dir / level / file_pattern);Docker 日志随统一目录持久化 - ✅ **v0.7.0**:**缓存命中/未命中分开计费(rant 2026-08-20T10:17:27)**——usage 解析拆分缓存 token(openai `prompt_tokens_details.cached_tokens` / anthropic `cache_read_input_tokens` / responses `input_tokens_details.cached_tokens`);计费 = 未命中 × input_per_m + 命中 × cache_hit_input_per_m(缺省 0 = 命中免费);usage_records 新增 `cached_tokens` 列(迁移 v8);DeepSeek 官方 CNY 定价(deepseek-v4-flash 1.5/0.05/4.5、deepseek-v4-pro 4.5/0.15/13.5,空闲价);管理端模型表单可配缓存命中输入价;流式(SSE 转换 + 透传)同拆 +- ✅ **v0.7.3**:**DeepSeek 高峰时段计价(rant 2026-08-20T11:58:40)**——models 表 + config `[[models]]` 新增三个可选高峰价字段 `peak_input_per_m / peak_output_per_m / peak_cache_hit_input_per_m`(缺省 0 = 不启用高峰计费,沿用空闲价);计费按**北京时间**判定高峰(9:00-12:00、14:00-18:00,周一至周日,固定 Asia/Shanghai 不依赖服务器时区)命中则用高峰价;DeepSeek 官方高峰价写入示例配置(flash 3.0/0.10/9.0、pro 9.0/0.30/27.0 元/M);市场页标注「高峰 ×N」+ 展开详情显示高峰价;管理端模型表单可配高峰价;迁移 v9 幂等;128 passed - ✅ **v0.7.2**:**seed 同步删除(rant 2026-08-20T11:58:33)**——启动 `seed_models` 在 upsert 之外同步删除 config 中已移除的模型行(config `[[models]]` 全量权威;市场不再显示幽灵模型;admin 运行时增删改下次启动被 config 覆盖/清掉,持久化自定义模型请写入 config.toml);新增测试 `seed_models_deletes_config_removed_rows`(125 passed) - ✅ **v0.7.1**:**简化模型配置(rant 2026-08-20T10:27:13)**——模型(厂商/价格/上下文/读图等)全部在 config.toml `[[models]]` 段定义(唯一真源),启动 upsert 进 DB;**移除 data/models.example.json 与 price_overrides 双层机制**(不再有「json 聚合源 + 官方价覆盖」设想);10 个模型迁入 config(DeepSeek 官方 CNY 价 flash 1.5/0.05/4.5、pro 4.5/0.15/13.5);data.js 游客兜底价对齐 config @@ -139,7 +140,7 @@ open http://localhost:8080/ # 浏览器访问 - `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`GET /api/me` → `{id,email,name,role}`;`GET /api/config` → `{public_url}`(接入端点 base,rant 2026-08-19T20:37:37) - `POST|GET /api/api-keys`(key 脱敏 `atk_live_****xxxx`);`DELETE /api/api-keys/:id`(撤销) - `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转,非流式 + 流式 SSE 跨协议转换);`GET /v1/models`(OpenAI 兼容模型列表,认证可选) -- `GET /api/models`(模型市场,含 context_length / max_output / vision / cache_hit_input_per_m) +- `GET /api/models`(模型市场,含 context_length / max_output / vision / cache_hit_input_per_m / peak_input_per_m / peak_output_per_m / peak_cache_hit_input_per_m) - `POST|GET /api/sharings` + `PATCH /api/sharings/:id`(上架 / 列表 / 暂停 / 恢复 / 删除) - `GET /api/wallet` / `GET /api/transactions?type=` / `GET /api/dashboard`(钱包 / 交易 / 仪表盘) - 管理员:`POST /api/admin/credits` / `GET /api/admin/users` + `PATCH /api/admin/users/:id` / `GET /api/admin/usage` diff --git a/config/config.example.toml b/config/config.example.toml index 1e86f94..cbafd0c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -246,7 +246,11 @@ endpoints = [ # 模型(厂商/名称/价格/上下文/读图等)全部在此定义;启动时 upsert 进 DB。 # 单位:每百万 tokens(currency 对应币种);input_per_m = 缓存未命中输入价, # cache_hit_input_per_m = 缓存命中输入价(缺省 0 = 命中免费)。 -# DeepSeek 为官方价(2026-08-20 浏览器核实,空闲时段;高峰 9-12/14-18 北京时翻倍)。 +# 高峰时段价(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,周一至周日)时按高峰价扣点。 +# DeepSeek 为官方价(2026-08-20 浏览器核实):空闲价即高峰价的一半, +# 高峰 9-12/14-18 北京时翻倍(flash 3.0/0.10/9.0,pro 9.0/0.30/27.0 元/M)。 # ============================================================ [[models]] @@ -256,6 +260,10 @@ currency = "CNY" input_per_m = 1.5 cache_hit_input_per_m = 0.05 output_per_m = 4.5 +# 高峰时段价(北京 9-12 / 14-18 翻倍;官方价) +peak_input_per_m = 3.0 +peak_cache_hit_input_per_m = 0.10 +peak_output_per_m = 9.0 context_length = 1048576 max_output = 384000 vision = false @@ -267,6 +275,10 @@ currency = "CNY" input_per_m = 4.5 cache_hit_input_per_m = 0.15 output_per_m = 13.5 +# 高峰时段价(北京 9-12 / 14-18 翻倍;官方价) +peak_input_per_m = 9.0 +peak_cache_hit_input_per_m = 0.30 +peak_output_per_m = 27.0 context_length = 1048576 max_output = 384000 vision = false diff --git a/docs/architecture.md b/docs/architecture.md index a9936fb..0c86d16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,7 @@ ### 4.3 计量引擎(P0-B 已实现,`src/billing.rs`) - 每次调用记录:用户、模型、输入/输出 token、成本(`usage_records`) - **点数计算**:`成本 = 输入token×input_per_m/1e6 + 输出token×output_per_m/1e6`(models 表单价,config.toml `[[models]]` 唯一真源)→ 折算到锚定货币(USD/CNY,`CNY_PER_USD=7.2`)→ × `points_per_unit`(默认 1000 点/USD) +- **高峰时段计价(v0.7.3)**:`[[models]]` 可选 `peak_input_per_m / peak_output_per_m / peak_cache_hit_input_per_m`(缺省 0 = 不启用);按北京时间(固定 Asia/Shanghai,UTC+8 无夏令时)判定高峰 9:00-12:00、14:00-18:00(周一至周日),命中则用高峰价(DeepSeek 官方高峰价 = 空闲价 ×2) - **入账(事务性)**:`settle` 在成功响应后调用——扣消费者 → 加分享者 90% → 两条 transactions(consume/earn)→ usage_records → keys.used;任一步失败整体回滚(失败不入账) ### 4.4 账本层(P1 点数规则已落地) @@ -122,7 +123,7 @@ users — 用户(email/password_hash[argon2]/name/role/created_at) keys — 上游 key(provider/plan/model/状态/属主/加密密文/额度/已用/可用时间段/备注) api_keys — 分发 key(绑定用户、atk_live_ 前缀、状态、last_used) -models — 模型价格(provider/model/currency/input_per_m/output_per_m) +models — 模型价格(provider/model/currency/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) quotas — 点数账户(balance 永久 + gift_balance 有效赠送) gift_grants — 赠送明细(amount/granted_at/expires_at/status: active|used|expired) transactions — 交易(counterpart/key_id/model/tokens/pts/type: consume|earn|topup/status/time) diff --git a/src/billing.rs b/src/billing.rs index b15b5aa..7b9b290 100644 --- a/src/billing.rs +++ b/src/billing.rs @@ -73,6 +73,48 @@ pub fn calc_points( to_anchor(cost, model_currency, anchor) * points_per_unit as f64 } +/// 北京时间高峰时段判定(rant 2026-08-20T11:58:40:DeepSeek 高峰 9:00-12:00、14:00-18:00, +/// 周一至周日,其余为空闲时段)。固定 Asia/Shanghai(UTC+8,无夏令时),不依赖服务器时区。 +pub fn is_peak_hour(utc: &chrono::DateTime) -> bool { + use chrono::Timelike; + let sh = utc.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).expect("+08:00 合法")); + let h = sh.hour(); + (9..12).contains(&h) || (14..18).contains(&h) +} + +/// 按是否高峰挑选生效价格:高峰时段命中且配置了高峰价(> 0)→ 用高峰价; +/// 高峰价缺省 0 → 沿用空闲价(不启用高峰计费的模型行为不变)。 +pub fn effective_prices( + peak: bool, + input_per_m: f64, + cache_hit_input_per_m: f64, + output_per_m: f64, + peak_input_per_m: f64, + peak_cache_hit_input_per_m: f64, + peak_output_per_m: f64, +) -> (f64, f64, f64) { + if !peak { + return (input_per_m, cache_hit_input_per_m, output_per_m); + } + ( + if peak_input_per_m > 0.0 { + peak_input_per_m + } else { + input_per_m + }, + if peak_cache_hit_input_per_m > 0.0 { + peak_cache_hit_input_per_m + } else { + cache_hit_input_per_m + }, + if peak_output_per_m > 0.0 { + peak_output_per_m + } else { + output_per_m + }, + ) +} + /// 入账参数 pub struct SettleParams { /// 消费者(调用者)user_id @@ -230,6 +272,121 @@ mod tests { assert!((pts_zero - expect_zero).abs() < 1e-9, "pts_zero={pts_zero}"); } + #[test] + fn is_peak_hour_beijing_boundaries() { + // rant 2026-08-20T11:58:40:高峰 = 北京时 9:00-12:00、14:00-18:00(周一至周日)。 + // 边界测试:8:59 vs 9:00、11:59 vs 12:00、13:59 vs 14:00、17:59 vs 18:00(北京时 = UTC+8) + fn beijing(h: u32, mi: u32) -> chrono::DateTime { + use chrono::TimeZone; + let dt = chrono::FixedOffset::east_opt(8 * 3600) + .unwrap() + .with_ymd_and_hms(2026, 8, 20, h, mi, 0) + .unwrap(); + dt.with_timezone(&chrono::Utc) + } + // 9:00 高峰开始 + assert!(is_peak_hour(&beijing(9, 0)), "北京 9:00 应高峰"); + assert!(!is_peak_hour(&beijing(8, 59)), "北京 8:59 应空闲"); + // 12:00 高峰结束 + assert!(!is_peak_hour(&beijing(12, 0)), "北京 12:00 应空闲"); + assert!(is_peak_hour(&beijing(11, 59)), "北京 11:59 应高峰"); + // 14:00 第二段高峰开始 + assert!(is_peak_hour(&beijing(14, 0)), "北京 14:00 应高峰"); + assert!(!is_peak_hour(&beijing(13, 59)), "北京 13:59 应空闲"); + // 18:00 第二段高峰结束 + assert!(!is_peak_hour(&beijing(18, 0)), "北京 18:00 应空闲"); + assert!(is_peak_hour(&beijing(17, 59)), "北京 17:59 应高峰"); + // 周日同样高峰(周一至周日全覆盖) + use chrono::TimeZone; + let sun = chrono::FixedOffset::east_opt(8 * 3600) + .unwrap() + .with_ymd_and_hms(2026, 8, 23, 10, 0, 0) + .unwrap() + .with_timezone(&chrono::Utc); + assert!(is_peak_hour(&sun), "周日 10:00 应高峰(含周末)"); + // 夜间空闲 + assert!(!is_peak_hour(&beijing(0, 0)), "北京 0:00 应空闲"); + assert!(!is_peak_hour(&beijing(23, 0)), "北京 23:00 应空闲"); + } + + #[test] + fn effective_prices_selects_peak_or_falls_back() { + // 空闲时段 → 一律空闲价 + assert_eq!( + effective_prices(false, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0), + (1.5, 0.05, 4.5) + ); + // 高峰时段 + 全量高峰价 → 用高峰价 + assert_eq!( + effective_prices(true, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0), + (3.0, 0.10, 9.0) + ); + // 高峰时段 + 高峰价缺省 0 → 沿用空闲价(不启用高峰计费的模型行为不变) + assert_eq!( + effective_prices(true, 1.5, 0.05, 4.5, 0.0, 0.0, 0.0), + (1.5, 0.05, 4.5) + ); + // 高峰时段 + 部分缺省 → 缺省项沿用空闲价 + assert_eq!( + effective_prices(true, 1.5, 0.05, 4.5, 3.0, 0.0, 9.0), + (3.0, 0.05, 9.0) + ); + } + + #[test] + fn calc_points_uses_peak_price_in_peak_hour() { + // 高峰 1M 输入 × 3.0 CNY/M = 3.0 CNY → USD 锚定 ÷7.2 ×1000 = 416.67 点;空闲 1.5 → 208.33 点 + let peak = effective_prices(true, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0); + let off = effective_prices(false, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0); + let pts_peak = calc_points( + 1_000_000.0, + 0.0, + 0.0, + peak.0, + peak.1, + peak.2, + 1000, + "CNY", + "USD", + ); + let pts_off = calc_points( + 1_000_000.0, + 0.0, + 0.0, + off.0, + off.1, + off.2, + 1000, + "CNY", + "USD", + ); + assert!( + (pts_peak - 3.0 / 7.2 * 1000.0).abs() < 1e-9, + "高峰 1M 输入 = 416.67 点: {pts_peak}" + ); + assert!( + (pts_off - 1.5 / 7.2 * 1000.0).abs() < 1e-9, + "空闲 1M 输入 = 208.33 点: {pts_off}" + ); + // 未配置高峰价 → 高峰/空闲点数一致 + let no_peak = effective_prices(true, 1.5, 0.05, 4.5, 0.0, 0.0, 0.0); + let pts_no = calc_points( + 1_000_000.0, + 0.0, + 0.0, + no_peak.0, + no_peak.1, + no_peak.2, + 1000, + "CNY", + "USD", + ); + assert!( + (pts_no - 1.5 / 7.2 * 1000.0).abs() < 1e-9, + "未配置高峰价 → 208.33 点: {pts_no}" + ); + } + #[test] fn settle_splits_90_10_and_writes_ledger() { let (mut conn, p) = tmp_db("settle"); diff --git a/src/config.rs b/src/config.rs index d1a10fd..3b48115 100644 --- a/src/config.rs +++ b/src/config.rs @@ -213,6 +213,16 @@ pub struct Model { pub cache_hit_input_per_m: f64, /// 每百万 tokens 输出价 pub output_per_m: f64, + /// 高峰时段输入价(rant 2026-08-20T11:58:40:DeepSeek 高峰 9-12/14-18 北京时翻倍; + /// 缺省 0 = 不启用高峰计费,沿用 input_per_m) + #[serde(default)] + pub peak_input_per_m: f64, + /// 高峰时段缓存命中输入价(缺省 0) + #[serde(default)] + pub peak_cache_hit_input_per_m: f64, + /// 高峰时段输出价(缺省 0) + #[serde(default)] + pub peak_output_per_m: f64, #[serde(default)] pub context_length: i64, #[serde(default)] @@ -328,6 +338,10 @@ mod tests { assert_eq!(dv.input_per_m, 4.5); assert_eq!(dv.cache_hit_input_per_m, 0.15); assert_eq!(dv.currency, "CNY"); + // 高峰价(rant 2026-08-20T11:58:40):pro 9.0 / 0.30 / 27.0 + assert_eq!(dv.peak_input_per_m, 9.0); + assert_eq!(dv.peak_cache_hit_input_per_m, 0.30); + assert_eq!(dv.peak_output_per_m, 27.0); let flash = cfg .models .iter() @@ -335,6 +349,11 @@ mod tests { .unwrap(); assert_eq!(flash.input_per_m, 1.5); assert_eq!(flash.cache_hit_input_per_m, 0.05); + assert_eq!(flash.peak_input_per_m, 3.0); + assert_eq!(flash.peak_output_per_m, 9.0); + // 未配置高峰价的模型 → 缺省 0(不启用高峰计费) + let zhipu = cfg.models.iter().find(|m| m.provider == "zhipu").unwrap(); + assert_eq!(zhipu.peak_input_per_m, 0.0, "无高峰价字段 → 缺省 0"); // server 默认值 assert_eq!(cfg.server.addr, "0.0.0.0:8080"); assert_eq!(cfg.server.db_path, "data/aitokenpool.db"); diff --git a/src/dao.rs b/src/dao.rs index 51289d0..c52e6eb 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -167,16 +167,30 @@ 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, currency) +/// 模型单价(按 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) +/// 高峰价缺省 0 = 不启用高峰计费(billing::effective_prices 处理回落) pub fn get_model_price( conn: &Connection, provider: &str, model: &str, -) -> Option<(f64, f64, f64, String)> { +) -> Option<(f64, f64, f64, f64, f64, f64, String)> { conn.query_row( - "SELECT input_per_m, output_per_m, cache_hit_input_per_m, currency FROM models WHERE provider = ?1 AND model = ?2", + "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 \ + 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| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + )) + }, ) .ok() } @@ -186,6 +200,7 @@ pub fn list_models_with_availability(conn: &Connection) -> Result Result(7)?, "vision": r.get::<_, i64>(8)?, "cache_hit_input_per_m": r.get::<_, f64>(9)?, - "available_keys": r.get::<_, i64>(10)?, + "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)?, + "available_keys": r.get::<_, i64>(13)?, })) })?; let mut out = Vec::new(); @@ -215,11 +233,12 @@ pub fn list_models_with_availability(conn: &Connection) -> Result Result> { 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, updated_at \ + 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 \ FROM models ORDER BY provider, model", )?; let rows = stmt.query_map([], |r| { - let updated_at: String = r.get(10)?; + let updated_at: String = r.get(13)?; Ok(serde_json::json!({ "id": r.get::<_, i64>(0)?, "provider": r.get::<_, String>(1)?, @@ -231,6 +250,9 @@ pub fn list_all_models(conn: &Connection) -> Result> { "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": utc_iso(&updated_at), })) })?; diff --git a/src/db.rs b/src/db.rs index f89cad5..198d0c5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; -pub const SCHEMA_VERSION: i64 = 8; +pub const SCHEMA_VERSION: i64 = 9; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -250,6 +250,26 @@ pub fn migrate(conn: &Connection) -> Result<()> { "cached_tokens", "cached_tokens REAL NOT NULL DEFAULT 0", )?; + // v9(rant 2026-08-20T11:58:40):DeepSeek 高峰时段(北京 9-12/14-18)价格翻倍—— + // models 补 peak_* 高峰价三字段(缺省 0 = 不启用高峰计费,沿用空闲价) + ensure_column( + conn, + "models", + "peak_input_per_m", + "peak_input_per_m REAL NOT NULL DEFAULT 0", + )?; + ensure_column( + conn, + "models", + "peak_cache_hit_input_per_m", + "peak_cache_hit_input_per_m REAL NOT NULL DEFAULT 0", + )?; + ensure_column( + conn, + "models", + "peak_output_per_m", + "peak_output_per_m REAL NOT NULL DEFAULT 0", + )?; // schema_version:INSERT OR REPLACE 保证幂等 let v: i64 = conn .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) @@ -424,8 +444,8 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> let mut n = 0u32; for m in &cfg.models { 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, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 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, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now')) \ ON CONFLICT(provider, model) DO UPDATE SET \ currency = excluded.currency, \ input_per_m = excluded.input_per_m, \ @@ -435,6 +455,9 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> max_output = excluded.max_output, \ vision = excluded.vision, \ cache_hit_input_per_m = excluded.cache_hit_input_per_m, \ + 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, \ updated_at = datetime('now')", rusqlite::params![ m.provider, @@ -447,6 +470,9 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> m.max_output, m.vision as i64, m.cache_hit_input_per_m, + m.peak_input_per_m, + m.peak_cache_hit_input_per_m, + m.peak_output_per_m, ], )?; n += 1; @@ -631,30 +657,71 @@ mod tests { .query_row("SELECT COUNT(*) FROM models", [], |r| r.get(0)) .unwrap(); assert!(cnt >= 10, "models 已从 config [[models]] seed,cnt={cnt}"); - // deepseek-v4-pro = config 官方 CNY 价(4.5 / 13.5 / 缓存命中 0.15) - let (input, output, cache_hit, currency): (f64, f64, f64, String) = conn + // deepseek-v4-pro = config 官方 CNY 价(4.5 / 13.5 / 缓存命中 0.15)+ 高峰价(9.0 / 27.0 / 0.30) + let (input, output, cache_hit, peak_in, peak_out, peak_cache, currency): ( + f64, + f64, + f64, + f64, + f64, + f64, + String, + ) = conn .query_row( - "SELECT input_per_m, output_per_m, cache_hit_input_per_m, currency FROM models \ - WHERE provider = 'deepseek' AND model = 'deepseek-v4-pro'", + "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 \ + FROM models WHERE provider = 'deepseek' AND model = 'deepseek-v4-pro'", [], - |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + )) + }, ) .unwrap(); assert!((input - 4.5).abs() < 1e-9, "input={input}"); assert!((output - 13.5).abs() < 1e-9, "output={output}"); assert!((cache_hit - 0.15).abs() < 1e-9, "cache_hit={cache_hit}"); + assert!((peak_in - 9.0).abs() < 1e-9, "peak input={peak_in}"); + assert!((peak_out - 27.0).abs() < 1e-9, "peak output={peak_out}"); + assert!( + (peak_cache - 0.30).abs() < 1e-9, + "peak cache_hit={peak_cache}" + ); assert_eq!(currency, "CNY"); - // flash 也在(config 直接定义) - let (fi, fh): (f64, f64) = conn + // flash 也在(config 直接定义;高峰 3.0 / 9.0 / 0.10) + let (fi, fh, fp_in, fp_out): (f64, f64, f64, f64) = conn .query_row( - "SELECT input_per_m, cache_hit_input_per_m FROM models \ - WHERE provider = 'deepseek' AND model = 'deepseek-v4-flash'", + "SELECT input_per_m, cache_hit_input_per_m, peak_input_per_m, peak_output_per_m \ + FROM models WHERE provider = 'deepseek' AND model = 'deepseek-v4-flash'", [], - |r| Ok((r.get(0)?, r.get(1)?)), + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .unwrap(); assert!((fi - 1.5).abs() < 1e-9, "flash input={fi}"); assert!((fh - 0.05).abs() < 1e-9, "flash cache_hit={fh}"); + assert!((fp_in - 3.0).abs() < 1e-9, "flash peak input={fp_in}"); + assert!((fp_out - 9.0).abs() < 1e-9, "flash peak output={fp_out}"); + // 无高峰价的模型 → peak 缺省 0(不启用高峰计费) + let (z_in, z_cache, z_out): (f64, f64, f64) = conn + .query_row( + "SELECT peak_input_per_m, peak_cache_hit_input_per_m, peak_output_per_m FROM models \ + WHERE provider = 'zhipu' AND model = 'glm-5.2'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!( + (z_in, z_cache, z_out), + (0.0, 0.0, 0.0), + "非 DeepSeek 模型高峰价缺省 0" + ); // 幂等:重复 seed 不报错且不产生重复行 seed_models(&conn, &cfg).expect("二次 seed"); let cnt2: i64 = conn diff --git a/src/gateway.rs b/src/gateway.rs index dcc1807..8dfa176 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -170,27 +170,32 @@ 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, currency)) => { + Some((i_per_m, o_per_m, cache_hit_per_m, p_in, p_out, p_cache, currency)) => { + // rant 2026-08-20T11:58:40:DeepSeek 高峰时段(北京 9-12/14-18)价格翻倍—— + // 按当前北京时间选高峰/空闲价;未配置高峰价的模型不受影响 + let peak = billing::is_peak_hour(&chrono::Utc::now()); + let (i2, c2, o2) = billing::effective_prices( + peak, + i_per_m, + cache_hit_per_m, + o_per_m, + p_in, + p_cache, + p_out, + ); let pts = billing::calc_points( input_tokens, cached_tokens, output_tokens, - i_per_m, - cache_hit_per_m, - o_per_m, + i2, + c2, + o2, st.cfg.points.points_per_unit, ¤cy, &st.cfg.points.anchor_currency, ); let cost = billing::to_anchor( - billing::raw_cost( - input_tokens, - cached_tokens, - output_tokens, - i_per_m, - cache_hit_per_m, - o_per_m, - ), + billing::raw_cost(input_tokens, cached_tokens, output_tokens, i2, c2, o2), ¤cy, &st.cfg.points.anchor_currency, ); diff --git a/src/routes/admin_models.rs b/src/routes/admin_models.rs index e5924ff..16d9220 100644 --- a/src/routes/admin_models.rs +++ b/src/routes/admin_models.rs @@ -2,7 +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?} +//! context_length?, max_output?, vision?, cache_hit_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:直接删除(模型删除后该 model 的调用无价格行 → 0 计费,README 说明) @@ -37,6 +38,13 @@ pub struct ModelCreate { pub vision: i64, #[serde(default)] pub cache_hit_input_per_m: f64, + /// 高峰时段价(rant 2026-08-20T11:58:40;缺省 0 = 不启用高峰计费) + #[serde(default)] + pub peak_input_per_m: f64, + #[serde(default)] + pub peak_output_per_m: f64, + #[serde(default)] + pub peak_cache_hit_input_per_m: f64, } fn default_currency() -> String { @@ -55,6 +63,9 @@ pub struct ModelPatch { pub max_output: Option, pub vision: Option, pub cache_hit_input_per_m: Option, + pub peak_input_per_m: Option, + pub peak_output_per_m: Option, + pub peak_cache_hit_input_per_m: Option, } /// 校验通用字段:价格 ≥ 0、vision ∈ {0,1}、currency 枚举 @@ -63,6 +74,9 @@ fn validate_common( input_per_m: f64, output_per_m: f64, vision: i64, + peak_input_per_m: f64, + peak_output_per_m: f64, + peak_cache_hit_input_per_m: f64, ) -> Result<(), ApiErr> { if !(currency == "USD" || currency == "CNY") { return Err(( @@ -70,7 +84,12 @@ fn validate_common( Json(serde_json::json!({ "error": "currency 仅支持 USD | CNY" })), )); } - if input_per_m < 0.0 || output_per_m < 0.0 { + if input_per_m < 0.0 + || output_per_m < 0.0 + || peak_input_per_m < 0.0 + || peak_output_per_m < 0.0 + || peak_cache_hit_input_per_m < 0.0 + { return Err(( axum::http::StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "价格不能为负数" })), @@ -98,13 +117,18 @@ fn row_to_json(r: &rusqlite::Row) -> rusqlite::Result { "max_output": r.get::<_, i64>(7)?, "vision": r.get::<_, i64>(8)?, "cache_hit_input_per_m": r.get::<_, f64>(9)?, - "updated_at": crate::dao::utc_iso(&r.get::<_, String>(10)?), - "context_window": r.get::<_, i64>(11)?, + "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)?, })) } 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, updated_at, context_window \ + 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 \ FROM models "; /// GET /api/admin/models:全部模型(管理表格数据源) @@ -133,7 +157,15 @@ pub async fn create( Json(serde_json::json!({ "error": "provider 与 model 不能为空" })), )); } - validate_common(&req.currency, req.input_per_m, req.output_per_m, req.vision)?; + validate_common( + &req.currency, + req.input_per_m, + req.output_per_m, + req.vision, + req.peak_input_per_m, + req.peak_output_per_m, + req.peak_cache_hit_input_per_m, + )?; let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; let dup: bool = conn .query_row( @@ -151,8 +183,10 @@ pub async fn create( let id: i64 = conn .query_row( "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, \ - context_length, max_output, vision, cache_hit_input_per_m, context_window, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now')) \ + 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')) \ RETURNING id", params![ provider, @@ -164,6 +198,9 @@ pub async fn create( req.max_output, req.vision, req.cache_hit_input_per_m, + req.peak_input_per_m, + req.peak_output_per_m, + req.peak_cache_hit_input_per_m, // OpenAI 兼容 context_window 与 context_length 对齐(/v1/models 用) req.context_length, ], @@ -199,10 +236,24 @@ pub async fn patch( )); } // 部分更新:先取当前值,合并后校验 + 写回(简单且避免逐列拼接 SQL) - let cur: (String, String, String, f64, f64, i64, i64, i64, f64) = conn + let cur: ( + String, + String, + String, + f64, + f64, + i64, + i64, + i64, + 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 \ + context_length, max_output, vision, cache_hit_input_per_m, \ + peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m \ FROM models WHERE id = ?1", [id], |r| { @@ -216,6 +267,9 @@ pub async fn patch( r.get(6)?, r.get(7)?, r.get(8)?, + r.get(9)?, + r.get(10)?, + r.get(11)?, )) }, ) @@ -229,13 +283,24 @@ 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); if provider.is_empty() || model.is_empty() { return Err(( axum::http::StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "provider 与 model 不能为空" })), )); } - validate_common(¤cy, input_per_m, output_per_m, vision)?; + validate_common( + ¤cy, + input_per_m, + output_per_m, + vision, + peak_input_per_m, + peak_output_per_m, + peak_cache_hit_input_per_m, + )?; // 改名时检查 (provider, model) 唯一(排除自身) let dup: bool = conn .query_row( @@ -253,8 +318,9 @@ 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, context_window = ?6, updated_at = datetime('now') \ - WHERE id = ?10", + 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') \ + WHERE id = ?13", params![ provider, model, @@ -265,6 +331,9 @@ pub async fn patch( max_output, vision, cache_hit_input_per_m, + peak_input_per_m, + peak_output_per_m, + peak_cache_hit_input_per_m, id, ], ) diff --git a/ui/index.html b/ui/index.html index 0b3bb84..ab72b59 100644 --- a/ui/index.html +++ b/ui/index.html @@ -543,6 +543,17 @@

添加模型

+
+ + + +