Skip to main content

yapi_ai/
thinking.rs

1//! Thinking levels and output token budgets.
2
3use yapi_types::message::{Message, ThinkingLevel};
4use yapi_types::model::Model;
5
6use crate::stream::{StreamOptions, ThinkingBudgets};
7use crate::transcript::estimate_context_tokens;
8
9const CONTEXT_SAFETY_TOKENS: u64 = 4096;
10/// Output tokens kept for the answer when a thinking budget would consume all.
11pub const MIN_ANSWER_TOKENS: u64 = 1024;
12
13/// The levels a model accepts: only `off` without reasoning, `xhigh` and `max` only
14/// when its thinking level map names them, and none the map marks unsupported.
15pub fn supported_levels(model: &Model) -> Vec<ThinkingLevel> {
16    if !model.reasoning {
17        return vec![ThinkingLevel::Off];
18    }
19    ThinkingLevel::ALL
20        .into_iter()
21        .filter(|level| match model.thinking_level_value(*level) {
22            Some(None) => false,
23            mapped if matches!(level, ThinkingLevel::Xhigh | ThinkingLevel::Max) => {
24                mapped.is_some()
25            }
26            _ => true,
27        })
28        .collect()
29}
30
31/// The nearest supported level: the requested one, else the next higher, else the
32/// next lower.
33pub fn clamp_level(model: &Model, level: ThinkingLevel) -> ThinkingLevel {
34    let supported = supported_levels(model);
35    if supported.contains(&level) {
36        return level;
37    }
38    ThinkingLevel::ALL
39        .into_iter()
40        .filter(|candidate| *candidate > level)
41        .chain(
42            ThinkingLevel::ALL
43                .into_iter()
44                .rev()
45                .filter(|candidate| *candidate < level),
46        )
47        .find(|candidate| supported.contains(candidate))
48        .unwrap_or(ThinkingLevel::Off)
49}
50
51/// Caps `max_tokens` so the estimated context plus output fits the window.
52pub fn clamp_max_tokens_to_context(model: &Model, messages: &[Message], max_tokens: u64) -> u64 {
53    if model.context_window == 0 {
54        return max_tokens.max(1);
55    }
56    let used = estimate_context_tokens(messages) + CONTEXT_SAFETY_TOKENS;
57    let available = model.context_window.saturating_sub(used).max(1);
58    max_tokens.min(available)
59}
60
61/// pi's `streamSimple` output limit: the requested limit, else the model's, capped
62/// to the context window.
63pub fn requested_max_tokens(model: &Model, messages: &[Message], options: &StreamOptions) -> u64 {
64    clamp_max_tokens_to_context(
65        model,
66        messages,
67        options.max_tokens.unwrap_or(model.max_tokens),
68    )
69}
70
71/// The requested level clamped to the model, or `None` when it is off.
72pub fn effort(model: &Model, options: &StreamOptions) -> Option<ThinkingLevel> {
73    options
74        .reasoning
75        .map(|level| clamp_level(model, level))
76        .filter(|level| *level != ThinkingLevel::Off)
77}
78
79/// Budget-based thinking at `level` on top of the output limit `base`: the output
80/// limit capped to the context, and the budget leaving room to answer under it.
81/// Returns `(max_tokens, budget)`.
82pub fn budgeted(
83    model: &Model,
84    messages: &[Message],
85    base: u64,
86    level: ThinkingLevel,
87    budgets: &ThinkingBudgets,
88) -> (u64, u64) {
89    let (adjusted, budget) = adjust_max_tokens_for_thinking(base, model.max_tokens, level, budgets);
90    let max_tokens = clamp_max_tokens_to_context(model, messages, adjusted);
91    (
92        max_tokens,
93        budget.min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS)),
94    )
95}
96
97/// The budget for budget-based thinking at `level`. `xhigh` and `max` use `high`.
98pub fn budget_for_level(level: ThinkingLevel, budgets: &ThinkingBudgets) -> u64 {
99    match level {
100        ThinkingLevel::Off | ThinkingLevel::Minimal => budgets.minimal.unwrap_or(1024),
101        ThinkingLevel::Low => budgets.low.unwrap_or(2048),
102        ThinkingLevel::Medium => budgets.medium.unwrap_or(8192),
103        ThinkingLevel::High | ThinkingLevel::Xhigh | ThinkingLevel::Max => {
104            budgets.high.unwrap_or(16384)
105        }
106    }
107}
108
109/// Raises `base` by the thinking budget, capped at the model's output limit, and
110/// shrinks the budget when it would leave no room to answer. Returns
111/// `(max_tokens, budget)`.
112fn adjust_max_tokens_for_thinking(
113    base: u64,
114    model_max_tokens: u64,
115    level: ThinkingLevel,
116    budgets: &ThinkingBudgets,
117) -> (u64, u64) {
118    let mut budget = budget_for_level(level, budgets);
119    let max_tokens = (base + budget).min(model_max_tokens);
120    if max_tokens <= budget {
121        budget = budget.min(max_tokens.saturating_sub(MIN_ANSWER_TOKENS));
122    }
123    (max_tokens, budget)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serde_json::json;
130
131    fn model(map: serde_json::Value) -> Model {
132        serde_json::from_value(json!({
133            "id":"m","name":"M","api":"a","provider":"p","baseUrl":"u","reasoning":true,"input":["text"],
134            "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000,"maxTokens":64000,
135            "thinkingLevelMap": map
136        }))
137        .unwrap()
138    }
139
140    #[test]
141    fn clamps_to_supported_levels() {
142        let model = model(json!({"minimal": null, "medium": null, "max": "max"}));
143        assert_eq!(
144            supported_levels(&model),
145            [
146                ThinkingLevel::Off,
147                ThinkingLevel::Low,
148                ThinkingLevel::High,
149                ThinkingLevel::Max
150            ]
151        );
152        assert_eq!(
153            clamp_level(&model, ThinkingLevel::Medium),
154            ThinkingLevel::High
155        );
156        assert_eq!(
157            clamp_level(&model, ThinkingLevel::Xhigh),
158            ThinkingLevel::Max
159        );
160        assert_eq!(
161            clamp_level(&model, ThinkingLevel::Minimal),
162            ThinkingLevel::Low
163        );
164    }
165
166    #[test]
167    fn budgets_match_pi() {
168        let budgets = ThinkingBudgets::default();
169        assert_eq!(
170            adjust_max_tokens_for_thinking(64000, 64000, ThinkingLevel::Medium, &budgets),
171            (64000, 8192)
172        );
173        assert_eq!(
174            adjust_max_tokens_for_thinking(1000, 2000, ThinkingLevel::High, &budgets),
175            (2000, 976)
176        );
177    }
178}