Skip to main content

agent_engine/runtime/openai/
reasoning.rs

1//! Provider-aware thinking/reasoning request helpers.
2
3use serde_json::{json, Map, Value};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum OpenAiReasoningProvider {
7    OpenRouter,
8    Groq,
9    NvidiaNim,
10    /// Kimi (Moonshot AI): level-driven exact capability wiring
11    /// (`catalog::kimi`), not budget-driven inference.
12    Kimi,
13    Generic,
14}
15
16pub fn thinking_level_for_budget(budget: u32) -> &'static str {
17    crate::core::models::thinking_level_for_budget(budget)
18}
19
20pub fn openai_effort_for_level(level: &str) -> &'static str {
21    match level {
22        "low" => "low",
23        "medium" | "med" => "medium",
24        "high" | "xhigh" => "high",
25        "adaptive" => "medium",
26        _ => "medium",
27    }
28}
29
30pub fn apply_openai_reasoning_params(
31    body: &mut Map<String, Value>,
32    provider: OpenAiReasoningProvider,
33    model: &str,
34    thinking_budget: u32,
35    reasoning_level: agent_core::reasoning::ReasoningLevel,
36) {
37    // Kimi is level-driven, not budget-driven: `Off` must still serialize
38    // (`thinking:{"type":"disabled"}` on toggleable models) and `Max` has no
39    // numeric budget, so the budget==0 guard below must not apply here.
40    // Exact per-model wiring lives in `catalog::kimi`.
41    if provider == OpenAiReasoningProvider::Kimi {
42        crate::runtime::openai::catalog::apply_kimi_reasoning_params(body, model, reasoning_level);
43        return;
44    }
45    // Don't inject reasoning params when thinking is disabled.
46    // Without this guard, non-reasoning models (e.g. llama-3.3) get
47    // unsupported fields that cause request failures.
48    if thinking_budget == 0 {
49        return;
50    }
51    let level = thinking_level_for_budget(thinking_budget);
52    match provider {
53        OpenAiReasoningProvider::OpenRouter => {
54            let effort = openai_effort_for_level(level);
55            body.insert("reasoning".to_string(), json!({ "effort": effort }));
56            body.insert("include_reasoning".to_string(), json!(true));
57        }
58        OpenAiReasoningProvider::Groq => {
59            if crate::runtime::openai::catalog::infer_groq_reasoning(model)
60                == crate::runtime::openai::catalog::ReasoningSupport::GroqReasoning
61            {
62                body.insert("reasoning_format".to_string(), json!("parsed"));
63                body.insert(
64                    "reasoning_effort".to_string(),
65                    json!(openai_effort_for_level(level)),
66                );
67            }
68        }
69        OpenAiReasoningProvider::Kimi => unreachable!("handled above"),
70        OpenAiReasoningProvider::NvidiaNim | OpenAiReasoningProvider::Generic => {}
71    }
72}
73
74pub fn provider_for_key(provider_key: &str) -> OpenAiReasoningProvider {
75    match provider_key {
76        "openrouter" => OpenAiReasoningProvider::OpenRouter,
77        "groq" => OpenAiReasoningProvider::Groq,
78        "nvidia" => OpenAiReasoningProvider::NvidiaNim,
79        "kimi" => OpenAiReasoningProvider::Kimi,
80        _ => OpenAiReasoningProvider::Generic,
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn openrouter_adds_reasoning_and_include_reasoning() {
90        let mut body = Map::new();
91        apply_openai_reasoning_params(
92            &mut body,
93            OpenAiReasoningProvider::OpenRouter,
94            "deepseek/deepseek-r1",
95            4096,
96            agent_core::reasoning::ReasoningLevel::Medium,
97        );
98        assert_eq!(body["reasoning"]["effort"], "medium");
99        assert_eq!(body["include_reasoning"], true);
100    }
101
102    #[test]
103    fn groq_adds_reasoning_only_for_reasoning_families() {
104        let mut body = Map::new();
105        apply_openai_reasoning_params(
106            &mut body,
107            OpenAiReasoningProvider::Groq,
108            "openai/gpt-oss-120b",
109            16_384,
110            agent_core::reasoning::ReasoningLevel::Medium,
111        );
112        assert_eq!(body["reasoning_format"], "parsed");
113        assert_eq!(body["reasoning_effort"], "high");
114
115        let mut plain = Map::new();
116        apply_openai_reasoning_params(
117            &mut plain,
118            OpenAiReasoningProvider::Groq,
119            "llama-3.3-70b-versatile",
120            16_384,
121            agent_core::reasoning::ReasoningLevel::Medium,
122        );
123        assert!(plain.is_empty());
124    }
125
126    #[test]
127    fn nvidia_and_generic_do_not_emit_unsupported_extra_fields() {
128        let mut body = Map::new();
129        apply_openai_reasoning_params(
130            &mut body,
131            OpenAiReasoningProvider::NvidiaNim,
132            "moonshotai/kimi-k2-thinking",
133            4096,
134            agent_core::reasoning::ReasoningLevel::Medium,
135        );
136        assert!(body.is_empty());
137        apply_openai_reasoning_params(
138            &mut body,
139            OpenAiReasoningProvider::Generic,
140            "some/model",
141            4096,
142            agent_core::reasoning::ReasoningLevel::Medium,
143        );
144        assert!(body.is_empty());
145    }
146
147    #[test]
148    fn zero_budget_skips_all_reasoning_params() {
149        let mut body = Map::new();
150        apply_openai_reasoning_params(
151            &mut body,
152            OpenAiReasoningProvider::OpenRouter,
153            "deepseek/deepseek-r1",
154            0,
155            agent_core::reasoning::ReasoningLevel::Medium,
156        );
157        assert!(
158            body.is_empty(),
159            "OpenRouter should not inject reasoning when budget is 0"
160        );
161
162        apply_openai_reasoning_params(
163            &mut body,
164            OpenAiReasoningProvider::Groq,
165            "openai/gpt-oss-120b",
166            0,
167            agent_core::reasoning::ReasoningLevel::Medium,
168        );
169        assert!(
170            body.is_empty(),
171            "Groq should not inject reasoning when budget is 0"
172        );
173    }
174
175    #[test]
176    fn kimi_key_maps_to_kimi_provider() {
177        assert_eq!(provider_for_key("kimi"), OpenAiReasoningProvider::Kimi);
178    }
179
180    #[test]
181    fn kimi_is_level_driven_not_budget_driven() {
182        use agent_core::reasoning::ReasoningLevel;
183        // Max has no numeric budget; the level must still reach the wire.
184        let mut body = Map::new();
185        apply_openai_reasoning_params(
186            &mut body,
187            OpenAiReasoningProvider::Kimi,
188            "kimi-k3",
189            0,
190            ReasoningLevel::Max,
191        );
192        assert_eq!(body["reasoning_effort"], "max");
193
194        // Off carries budget 0 but must still serialize the disable toggle
195        // on toggleable models — the zero-budget guard must not swallow it.
196        let mut body = Map::new();
197        apply_openai_reasoning_params(
198            &mut body,
199            OpenAiReasoningProvider::Kimi,
200            "kimi-k2.6",
201            0,
202            ReasoningLevel::Off,
203        );
204        assert_eq!(body["thinking"]["type"], "disabled");
205
206        // Adaptive omits every reasoning field (provider default).
207        let mut body = Map::new();
208        apply_openai_reasoning_params(
209            &mut body,
210            OpenAiReasoningProvider::Kimi,
211            "kimi-k3",
212            0,
213            ReasoningLevel::Adaptive,
214        );
215        assert!(body.is_empty());
216    }
217}