Skip to main content

ai_profile/
limits.rs

1//! 模型的 token 限额 —— 上下文窗口与单次输出上限。
2//!
3//! # 🔴 这不是一张「全量模型能力表」
4//!
5//! 本模块**刻意不做** models.dev / LiteLLM 那种事。那类项目维护着上万条模型的
6//! 上下文、价格、能力矩阵(models.dev 至今一万多次提交),而那份数据周级变动、
7//! 且同一个模型在不同中转站的实际限额并不相同 —— 我们既没有那个维护量,
8//! 也无从知道某个中转站到底给用户开了多大的窗口。
9//!
10//! 真要全量能力表,应用自己去拉 <https://models.dev/api.json>,那是它的专业。
11//!
12//! 本模块只回答一个具体问题:**「发这次请求之前,该按多大的窗口裁历史?」**
13//!
14//! # 分层回退,且来源可分辨
15//!
16//! ```text
17//! 0. 用户手填       ← 用户明确说了算,压过一切(端点报错、中转站另有限制时的出口)
18//! 1. 端点实时上报   ← 最准:中转站报的就是它自己的真实限额
19//! 2. 预置静态兜底   ← 端点不报时用;只写「保守够用」而非追新
20//! 3. 都没有 → None  ← 让用户自己填,别猜
21//! ```
22//!
23//! 合并用 [`TokenLimits::or`],**逐字段**回退:用户只填了窗口,输出上限照样能从下一层拿。
24//!
25//! 🔴 **三者必须能被调用方分辨**,这是本模块最重要的设计。
26//!
27//! 调研真实故障时,几乎每一条都源于「把猜的数字当成真的」:
28//!
29//! - 某网关丢掉元数据 → 客户端回落硬编码表 → 512K 的模型被当成 131K,提前触发压缩
30//! - 某应用钉死 65536,而实测请求中位数 153395、p90 达 433535
31//! - 某扩展硬编码 288K,同时无视服务器上报值**和**用户设置
32//!
33//! 共同点不是「数字错了」,而是**错了也看不出来**。所以 [`TokenLimits::source`]
34//! 是必填字段:界面可以据此显示「端点上报 128K」还是「预估 128K,可修改」。
35//!
36//! # 为什么 OpenAI 兼容端点大多不报
37//!
38//! **OpenAI 的 `/v1/models` 规范里就没有 context 字段**。实测:
39//!
40//! | 端点 | `/models` 是否带上下文 |
41//! |---|---|
42//! | OpenRouter | ✅ `context_length` 100% 覆盖,还有 `top_provider.max_completion_tokens` |
43//! | DeepSeek | ✅ `context_window` + `max_output_tokens`(2026-09-23 真实密钥实测) |
44//! | LM Studio / Ollama 的兼容层 | ❌ 只有 `{id, object, owned_by}`(原生 `/api/v1/models` 才有) |
45//!
46//! 所以静态兜底不是可选项 —— 不做的话,多数端点上这个能力等于不存在。
47
48use serde::{Deserialize, Serialize};
49
50/// 限额数字的来源。
51///
52/// 🔴 调用方**必须**据此区别对待:端点上报的可以直接用,静态兜底的应当让用户能改。
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55#[non_exhaustive]
56pub enum LimitSource {
57    /// 用户在界面上手填的 —— 优先级最高。
58    ///
59    /// 端点报的也可能不对(中转站按套餐另有限制却照抄模型标称值),
60    /// 这时唯一的出口是让用户改;改了就必须压过端点值,否则改了等于没改。
61    User,
62    /// 端点在 `/models` 响应里自己报的 —— 最可信,包含中转站的真实限制
63    Endpoint,
64    /// 本 crate 的预置静态值 —— 保守估计,可能过时,应允许用户覆盖
65    Preset,
66}
67
68impl LimitSource {
69    /// 规范字符串,与 serde 线格式一致。调用方持久化来源时用它。
70    pub const fn as_str(self) -> &'static str {
71        match self {
72            LimitSource::User => "user",
73            LimitSource::Endpoint => "endpoint",
74            LimitSource::Preset => "preset",
75        }
76    }
77
78    /// 从 [`Self::as_str`] 的产物解析回来;认不出返回 `None`。
79    pub fn parse(s: &str) -> Option<Self> {
80        match s.trim() {
81            "user" => Some(LimitSource::User),
82            "endpoint" => Some(LimitSource::Endpoint),
83            "preset" => Some(LimitSource::Preset),
84            _ => None,
85        }
86    }
87}
88
89/// 一个模型的 token 限额。
90///
91/// 两个字段都是 `Option`:拿不到就是拿不到,**不填一个猜的数字**。
92/// 调用方遇到 `None` 应当让用户手填,而不是自己兜一个默认值 ——
93/// 那正是要避免的「硬编码回落」。
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
95#[serde(rename_all = "camelCase")]
96#[non_exhaustive]
97pub struct TokenLimits {
98    /// 上下文窗口(输入 + 输出的总上限)。
99    ///
100    /// 用途:裁历史消息。注意它是**总量**,留给输出的部分要从中扣除。
101    pub context_window: Option<u32>,
102    /// 单次输出上限。
103    ///
104    /// 用途:填请求里的 `max_tokens`。此前各应用普遍硬编码 4096 ——
105    /// 对支持 64K 输出的模型来说白白浪费了大半能力。
106    pub max_output: Option<u32>,
107    /// 整条的来源:优先级最高、真正起作用的那一层,见 [`LimitSource`]。
108    ///
109    /// 两个值可能来自不同的层(用户只填了窗口、输出上限由预置补),
110    /// 要分别标注请用 [`Self::context_window_source`] / [`Self::max_output_source`]。
111    pub source: LimitSource,
112    /// `context_window` 这个值来自哪一层;值为 `None` 时也为 `None`。
113    pub context_window_source: Option<LimitSource>,
114    /// `max_output` 这个值来自哪一层;值为 `None` 时也为 `None`。
115    pub max_output_source: Option<LimitSource>,
116}
117
118/// 有值才有来源。
119const fn source_of(value: Option<u32>, source: LimitSource) -> Option<LimitSource> {
120    match value {
121        Some(_) => Some(source),
122        None => None,
123    }
124}
125
126impl TokenLimits {
127    /// 端点上报的限额。
128    pub const fn from_endpoint(context_window: Option<u32>, max_output: Option<u32>) -> Self {
129        Self::with_source(context_window, max_output, LimitSource::Endpoint)
130    }
131
132    /// 预置静态兜底。
133    pub const fn from_preset(context_window: Option<u32>, max_output: Option<u32>) -> Self {
134        Self::with_source(context_window, max_output, LimitSource::Preset)
135    }
136
137    /// 用户手填的限额。
138    pub const fn from_user(context_window: Option<u32>, max_output: Option<u32>) -> Self {
139        Self::with_source(context_window, max_output, LimitSource::User)
140    }
141
142    /// 按来源构造 —— 调用方从存储里读回 `(窗口, 输出, 来源)` 时用。
143    ///
144    /// 两个值都记为同一来源;逐字段来源只在 [`Self::or`] 合并不同层时才会不同。
145    pub const fn with_source(
146        context_window: Option<u32>,
147        max_output: Option<u32>,
148        source: LimitSource,
149    ) -> Self {
150        Self {
151            context_window,
152            max_output,
153            source,
154            context_window_source: source_of(context_window, source),
155            max_output_source: source_of(max_output, source),
156        }
157    }
158
159    /// 逐字段回退:自己缺的字段从 `fallback` 补,**值和它的来源一起补**。
160    ///
161    /// 整条的 `source` 取**自己的**(只要自己至少有一个字段)—— 它标的是优先级最高、
162    /// 真正起作用的那一层。自己全空时整个换成 `fallback`。
163    /// 每个值实际来自哪一层看 `context_window_source` / `max_output_source`。
164    ///
165    /// 🔴 为什么要逐字段:用户常常只知道窗口大小(文档写了),不知道输出上限。
166    /// 整条替换的话,填了窗口就丢了预置里的输出上限,等于填了反而变差。
167    ///
168    /// ```
169    /// # use ai_profile::limits::{LimitSource, TokenLimits};
170    /// let user = TokenLimits::from_user(Some(64_000), None);
171    /// let preset = TokenLimits::from_preset(Some(128_000), Some(8192));
172    /// let l = user.or(preset);
173    /// assert_eq!(l.context_window, Some(64_000)); // 用户的
174    /// assert_eq!(l.max_output, Some(8192));       // 预置补的
175    /// assert_eq!(l.source, LimitSource::User);
176    /// ```
177    pub const fn or(self, fallback: TokenLimits) -> TokenLimits {
178        if self.is_empty() {
179            return fallback;
180        }
181        let (context_window, context_window_source) = match self.context_window {
182            Some(v) => (Some(v), self.context_window_source),
183            None => (fallback.context_window, fallback.context_window_source),
184        };
185        let (max_output, max_output_source) = match self.max_output {
186            Some(v) => (Some(v), self.max_output_source),
187            None => (fallback.max_output, fallback.max_output_source),
188        };
189        TokenLimits {
190            context_window,
191            max_output,
192            source: self.source,
193            context_window_source,
194            max_output_source,
195        }
196    }
197
198    /// 两个数字都没有 —— 等于「不知道」,调用方该让用户填。
199    pub const fn is_empty(&self) -> bool {
200        self.context_window.is_none() && self.max_output.is_none()
201    }
202
203    /// 留给输入的预算 = 上下文窗口 − 为输出预留的量。
204    ///
205    /// 这是裁历史时真正要用的数字。`reserve_output` 传本次请求实际要用的
206    /// `max_tokens`,而不是模型的输出上限 —— 用上限会把预算压得过小。
207    ///
208    /// 上下文窗口未知时返回 `None`:**不猜**。
209    ///
210    /// ```
211    /// # use ai_profile::limits::TokenLimits;
212    /// let l = TokenLimits::from_endpoint(Some(128_000), Some(8192));
213    /// assert_eq!(l.input_budget(4096), Some(123_904));
214    ///
215    /// // 预留量比窗口还大 —— 返回 0 而不是下溢
216    /// let tiny = TokenLimits::from_preset(Some(1000), None);
217    /// assert_eq!(tiny.input_budget(4096), Some(0));
218    ///
219    /// // 不知道窗口就是不知道
220    /// assert_eq!(TokenLimits::from_preset(None, None).input_budget(4096), None);
221    /// ```
222    pub fn input_budget(&self, reserve_output: u32) -> Option<u32> {
223        self.context_window
224            .map(|w| w.saturating_sub(reserve_output))
225    }
226}
227
228/// `/models` 条目里表示上下文窗口的字段,**按顺序**取第一个有效值。
229///
230/// 这两张表公开是为了让多语言规范直接导出(`spec/conformance/models_response.json` 的 `rules`),
231/// 顺序本身就是规则的一部分。
232pub const CONTEXT_WINDOW_FIELDS: &[&str] = &[
233    "context_length",
234    "context_window",
235    "max_context_length",
236    "max_input_tokens",
237];
238
239/// `/models` 条目里表示单次输出上限的字段,**按顺序**取第一个有效值。
240pub const MAX_OUTPUT_FIELDS: &[&str] =
241    &["max_completion_tokens", "max_output_tokens", "max_tokens"];
242
243/// 从 `/models` 响应的**单条**模型对象里抽限额。
244///
245/// 各家字段名不统一。每个值的取法(上下文窗口用 [`CONTEXT_WINDOW_FIELDS`],输出上限用
246/// [`MAX_OUTPUT_FIELDS`]):
247///
248/// 1. 先按表的顺序查 `top_provider` 对象里的字段,取第一个有效值
249/// 2. 都没有,再按同样顺序查顶层字段
250///
251/// 两个值各自独立走这个过程 —— `top_provider` 只报了窗口时,输出上限仍会回落到顶层。
252///
253/// 「有效值」:正整数;非负浮点取整;数字字符串(两端空白去掉后)按整数解析;
254/// **0 视为没有**(有的端点用 0 表示未知);负数、超出 u32 的值视为没有。
255///
256/// 🔴 `top_provider` 优先不是凑数:OpenRouter 的顶层 `context_length` 是**模型本体**的,
257/// 而 `top_provider.context_length` 是**当前这家服务商实际提供**的,后者才是用户
258/// 真正能用到的。实测两者会不一致,所以嵌套那份优先级更高。
259///
260/// 返回 `None` 表示这条模型一个限额字段都没有 —— 对 OpenAI 规范的兼容端点是常态。
261pub fn parse_model_limits(item: &serde_json::Value) -> Option<TokenLimits> {
262    /// 读一个非负整数;浮点与数字字符串也接受(有些网关把它序列化成字符串)
263    fn num(v: Option<&serde_json::Value>) -> Option<u32> {
264        let v = v?;
265        let n = v
266            .as_u64()
267            .or_else(|| v.as_f64().filter(|f| *f >= 0.0).map(|f| f as u64))
268            .or_else(|| v.as_str()?.trim().parse::<u64>().ok())?;
269        // 0 当作「没有」:某些端点用 0 表示未知,当成真实上限会让预算算成 0
270        if n == 0 {
271            return None;
272        }
273        u32::try_from(n).ok()
274    }
275
276    let top = item.get("top_provider");
277    let pick = |keys: &[&str]| -> Option<u32> {
278        // 🔴 先看 top_provider:它是「这家实际给你的」,比模型本体的标称值更贴近现实
279        for k in keys {
280            if let Some(n) = num(top.and_then(|t| t.get(*k))) {
281                return Some(n);
282            }
283        }
284        keys.iter().find_map(|k| num(item.get(*k)))
285    };
286
287    let context_window = pick(CONTEXT_WINDOW_FIELDS);
288    let max_output = pick(MAX_OUTPUT_FIELDS);
289
290    if context_window.is_none() && max_output.is_none() {
291        return None;
292    }
293    Some(TokenLimits::from_endpoint(context_window, max_output))
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use serde_json::json;
300
301    /// 🔴 `top_provider` 的优先级高于顶层 —— 它才是用户真能用到的额度。
302    ///
303    /// 这条用的是 OpenRouter 的真实结构:顶层 `context_length` 是模型本体标称值,
304    /// 而某家服务商可能只提供其中一部分。取错了会让裁历史按一个用不到的大数字算,
305    /// 表现为「明明裁过了还是超限」。
306    #[test]
307    fn nested_top_provider_wins() {
308        let item = json!({
309            "id": "some/model",
310            "context_length": 1_048_576,
311            "top_provider": { "context_length": 131_072, "max_completion_tokens": 32_768 }
312        });
313        let l = parse_model_limits(&item).unwrap();
314        assert_eq!(l.context_window, Some(131_072), "该取服务商实际提供的");
315        assert_eq!(l.max_output, Some(32_768));
316        assert_eq!(l.source, LimitSource::Endpoint);
317    }
318
319    /// 没有 top_provider 时回落到顶层字段。
320    #[test]
321    fn falls_back_to_top_level() {
322        let item = json!({ "id": "m", "context_length": 200_000 });
323        let l = parse_model_limits(&item).unwrap();
324        assert_eq!(l.context_window, Some(200_000));
325        assert_eq!(l.max_output, None, "没报就是没报,不猜");
326    }
327
328    /// 🔴 OpenAI 规范的裸响应 —— LM Studio / Ollama 的兼容层都是这样。
329    ///
330    /// 这是**最常见**的情况,不是边角料:OpenAI 的 /v1/models 规范里根本没有
331    /// context 字段。返回 None 才能让调用方走静态兜底那一层。
332    #[test]
333    fn bare_openai_shape_yields_none() {
334        let item = json!({ "id": "qwen3-8b", "object": "model", "owned_by": "organization_owner" });
335        assert!(parse_model_limits(&item).is_none());
336    }
337
338    /// DeepSeek `/models` 的真实结构(2026-09-23 真实密钥实测):
339    /// 顶层直接带 `context_window` + `max_output_tokens`,没有 top_provider。
340    ///
341    /// 此前曾误以为 DeepSeek 不报限额,这条用实测形状钉住,防止再次误判。
342    #[test]
343    fn deepseek_shape_is_parsed() {
344        let item = json!({
345            "id": "deepseek-flash",
346            "object": "model",
347            "owned_by": "deepseek",
348            "context_window": 1_048_576,
349            "max_output_tokens": 393_216
350        });
351        let l = parse_model_limits(&item).unwrap();
352        assert_eq!(l.context_window, Some(1_048_576));
353        assert_eq!(l.max_output, Some(393_216));
354        assert_eq!(l.source, LimitSource::Endpoint);
355    }
356
357    /// 🔴 来源也要逐字段:用户只填了窗口、输出上限来自预置时,界面要能分别标出
358    /// 「窗口:你填的」「输出上限:预估,可修改」。整条只有一个 `source` 时两个都会被标成「你填的」。
359    #[test]
360    fn or_tracks_source_per_field() {
361        let user = TokenLimits::from_user(Some(64_000), None);
362        let endpoint = TokenLimits::from_endpoint(None, None);
363        let preset = TokenLimits::from_preset(Some(128_000), Some(8192));
364        let l = user.or(endpoint).or(preset);
365        assert_eq!(l.context_window_source, Some(LimitSource::User));
366        assert_eq!(l.max_output_source, Some(LimitSource::Preset));
367        assert_eq!(
368            l.source,
369            LimitSource::User,
370            "整条的 source 语义不变,兼容已有调用方"
371        );
372
373        // 没有值的字段没有来源
374        let only_window = TokenLimits::from_endpoint(Some(200_000), None);
375        assert_eq!(
376            only_window.context_window_source,
377            Some(LimitSource::Endpoint)
378        );
379        assert_eq!(only_window.max_output_source, None);
380
381        // 从存储读回(with_source)同样逐字段标注
382        let stored = TokenLimits::with_source(Some(1), Some(2), LimitSource::User);
383        assert_eq!(stored.context_window_source, Some(LimitSource::User));
384        assert_eq!(stored.max_output_source, Some(LimitSource::User));
385
386        // 线格式:前端按字段读来源
387        let j = serde_json::to_string(&l).unwrap();
388        assert!(j.contains(r#""contextWindowSource":"user""#), "{j}");
389        assert!(j.contains(r#""maxOutputSource":"preset""#), "{j}");
390    }
391
392    /// 逐字段回退 + 来源取高优先级那层;自己全空时整条换成 fallback。
393    #[test]
394    fn or_merges_field_by_field() {
395        let endpoint = TokenLimits::from_endpoint(None, Some(32_000));
396        let preset = TokenLimits::from_preset(Some(128_000), Some(8192));
397        let l = endpoint.or(preset);
398        assert_eq!(l.context_window, Some(128_000), "端点没报窗口,由预置补");
399        assert_eq!(l.max_output, Some(32_000), "端点报了的不被覆盖");
400        assert_eq!(l.source, LimitSource::Endpoint);
401
402        let empty = TokenLimits::from_user(None, None);
403        assert_eq!(
404            empty.or(preset),
405            preset,
406            "空的用户设置不能把来源冒充成 User"
407        );
408    }
409
410    /// 来源的持久化字符串必须能原样读回,且与 serde 线格式一致。
411    #[test]
412    fn limit_source_roundtrip() {
413        for s in [
414            LimitSource::User,
415            LimitSource::Endpoint,
416            LimitSource::Preset,
417        ] {
418            assert_eq!(LimitSource::parse(s.as_str()), Some(s));
419            assert_eq!(serde_json::to_value(s).unwrap(), s.as_str());
420        }
421        assert_eq!(LimitSource::parse("guess"), None);
422    }
423
424    /// 0 不是「上限为 0」,是「未知」。
425    ///
426    /// 当成真值会让 input_budget 算出 0,表现为「历史全被裁光,模型失忆」。
427    #[test]
428    fn zero_means_unknown() {
429        let item = json!({ "id": "m", "context_length": 0, "max_output_tokens": 0 });
430        assert!(parse_model_limits(&item).is_none());
431    }
432
433    /// 数字被序列化成字符串时也要认(部分自建网关如此)。
434    #[test]
435    fn accepts_stringified_numbers() {
436        let item = json!({ "id": "m", "context_length": "32768" });
437        assert_eq!(
438            parse_model_limits(&item).unwrap().context_window,
439            Some(32_768)
440        );
441    }
442
443    /// 🔴 来源必须能被分辨 —— 这是本模块存在的核心理由。
444    #[test]
445    fn source_is_distinguishable() {
446        assert_eq!(
447            parse_model_limits(&json!({ "context_length": 1 }))
448                .unwrap()
449                .source,
450            LimitSource::Endpoint
451        );
452        assert_eq!(
453            TokenLimits::from_preset(Some(1), None).source,
454            LimitSource::Preset
455        );
456    }
457
458    /// 线格式是前端契约 —— 字段名变了下游会静默读到 undefined。
459    #[test]
460    fn serializes_camel_case_with_snake_source() {
461        let l = TokenLimits::from_endpoint(Some(128_000), Some(8192));
462        let j = serde_json::to_string(&l).unwrap();
463        assert!(j.contains(r#""contextWindow":128000"#), "{j}");
464        assert!(j.contains(r#""maxOutput":8192"#), "{j}");
465        // 来源枚举走 snake_case,与 VerifyError 的 code 口径一致
466        assert!(j.contains(r#""source":"endpoint""#), "{j}");
467    }
468
469    /// 预算计算不能下溢 —— 预留量大于窗口时返回 0。
470    #[test]
471    fn budget_saturates_instead_of_underflowing() {
472        let l = TokenLimits::from_preset(Some(4096), None);
473        assert_eq!(l.input_budget(8192), Some(0));
474    }
475}