1use 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;
10pub const MIN_ANSWER_TOKENS: u64 = 1024;
12
13pub 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
31pub 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
51pub 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
61pub 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
71pub 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
79pub 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
97pub 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
109fn 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}