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
51 changes: 46 additions & 5 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 内)
Expand Down Expand Up @@ -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
Expand All @@ -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 条)。
Expand Down Expand Up @@ -551,19 +569,33 @@ 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
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"
Expand All @@ -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
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 明细列) |
Expand Down
78 changes: 78 additions & 0 deletions src/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)));
}
}
84 changes: 61 additions & 23 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ModelTier>,
#[serde(default)]
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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} 长档输出价");
}
}

Expand Down
Loading
Loading