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        // Both Kimi routes share the level-driven exact-id wiring in
80        // `catalog::kimi` (static Moonshot platform + managed Kimi Code).
81        "kimi" | "kimi-code" => OpenAiReasoningProvider::Kimi,
82        _ => OpenAiReasoningProvider::Generic,
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn openrouter_adds_reasoning_and_include_reasoning() {
92        let mut body = Map::new();
93        apply_openai_reasoning_params(
94            &mut body,
95            OpenAiReasoningProvider::OpenRouter,
96            "deepseek/deepseek-r1",
97            4096,
98            agent_core::reasoning::ReasoningLevel::Medium,
99        );
100        assert_eq!(body["reasoning"]["effort"], "medium");
101        assert_eq!(body["include_reasoning"], true);
102    }
103
104    #[test]
105    fn groq_adds_reasoning_only_for_reasoning_families() {
106        let mut body = Map::new();
107        apply_openai_reasoning_params(
108            &mut body,
109            OpenAiReasoningProvider::Groq,
110            "openai/gpt-oss-120b",
111            16_384,
112            agent_core::reasoning::ReasoningLevel::Medium,
113        );
114        assert_eq!(body["reasoning_format"], "parsed");
115        assert_eq!(body["reasoning_effort"], "high");
116
117        let mut plain = Map::new();
118        apply_openai_reasoning_params(
119            &mut plain,
120            OpenAiReasoningProvider::Groq,
121            "llama-3.3-70b-versatile",
122            16_384,
123            agent_core::reasoning::ReasoningLevel::Medium,
124        );
125        assert!(plain.is_empty());
126    }
127
128    #[test]
129    fn nvidia_and_generic_do_not_emit_unsupported_extra_fields() {
130        let mut body = Map::new();
131        apply_openai_reasoning_params(
132            &mut body,
133            OpenAiReasoningProvider::NvidiaNim,
134            "moonshotai/kimi-k2-thinking",
135            4096,
136            agent_core::reasoning::ReasoningLevel::Medium,
137        );
138        assert!(body.is_empty());
139        apply_openai_reasoning_params(
140            &mut body,
141            OpenAiReasoningProvider::Generic,
142            "some/model",
143            4096,
144            agent_core::reasoning::ReasoningLevel::Medium,
145        );
146        assert!(body.is_empty());
147    }
148
149    #[test]
150    fn zero_budget_skips_all_reasoning_params() {
151        let mut body = Map::new();
152        apply_openai_reasoning_params(
153            &mut body,
154            OpenAiReasoningProvider::OpenRouter,
155            "deepseek/deepseek-r1",
156            0,
157            agent_core::reasoning::ReasoningLevel::Medium,
158        );
159        assert!(
160            body.is_empty(),
161            "OpenRouter should not inject reasoning when budget is 0"
162        );
163
164        apply_openai_reasoning_params(
165            &mut body,
166            OpenAiReasoningProvider::Groq,
167            "openai/gpt-oss-120b",
168            0,
169            agent_core::reasoning::ReasoningLevel::Medium,
170        );
171        assert!(
172            body.is_empty(),
173            "Groq should not inject reasoning when budget is 0"
174        );
175    }
176
177    #[test]
178    fn kimi_key_maps_to_kimi_provider() {
179        assert_eq!(provider_for_key("kimi"), OpenAiReasoningProvider::Kimi);
180    }
181
182    #[test]
183    fn kimi_is_level_driven_not_budget_driven() {
184        use agent_core::reasoning::ReasoningLevel;
185        // Max has no numeric budget; the level must still reach the wire.
186        let mut body = Map::new();
187        apply_openai_reasoning_params(
188            &mut body,
189            OpenAiReasoningProvider::Kimi,
190            "kimi-k3",
191            0,
192            ReasoningLevel::Max,
193        );
194        assert_eq!(body["reasoning_effort"], "max");
195
196        // Off carries budget 0 but must still serialize the disable toggle
197        // on toggleable models — the zero-budget guard must not swallow it.
198        let mut body = Map::new();
199        apply_openai_reasoning_params(
200            &mut body,
201            OpenAiReasoningProvider::Kimi,
202            "kimi-k2.6",
203            0,
204            ReasoningLevel::Off,
205        );
206        assert_eq!(body["thinking"]["type"], "disabled");
207
208        // Adaptive omits every reasoning field (provider default).
209        let mut body = Map::new();
210        apply_openai_reasoning_params(
211            &mut body,
212            OpenAiReasoningProvider::Kimi,
213            "kimi-k3",
214            0,
215            ReasoningLevel::Adaptive,
216        );
217        assert!(body.is_empty());
218    }
219}