1use std::sync::atomic::{AtomicU64, Ordering};
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::core::config::Effort;
35
36fn openai_value(effort: Effort) -> &'static str {
39 match effort {
40 Effort::Minimal => "minimal",
41 Effort::Low => "low",
42 Effort::Medium => "medium",
43 Effort::High => "high",
44 }
45}
46
47fn anthropic_value(effort: Effort) -> &'static str {
50 match effort {
51 Effort::Minimal | Effort::Low => "low",
52 Effort::Medium => "medium",
53 Effort::High => "high",
54 }
55}
56
57#[must_use]
65pub fn openai_supports_effort(model: &str) -> bool {
66 let bare = model
67 .rsplit('/')
68 .next()
69 .unwrap_or(model)
70 .trim()
71 .to_ascii_lowercase();
72 if bare.is_empty() || bare.contains("chat") {
73 return false;
74 }
75 bare.starts_with("o1")
76 || bare.starts_with("o3")
77 || bare.starts_with("o4")
78 || bare.starts_with("gpt-5")
79 || bare.starts_with("gpt-6")
80 || bare.contains("codex")
81}
82
83pub fn apply_openai_chat(doc: &mut Value, effort: Effort) -> bool {
87 let Some(obj) = doc.as_object_mut() else {
88 return false;
89 };
90 if obj.contains_key("reasoning_effort") {
91 return false; }
93 let model = obj.get("model").and_then(Value::as_str).unwrap_or_default();
94 if !openai_supports_effort(model) {
95 return false;
96 }
97 obj.insert(
98 "reasoning_effort".to_string(),
99 Value::String(openai_value(effort).to_string()),
100 );
101 record(Provider::OpenAi);
102 true
103}
104
105pub fn apply_openai_responses(doc: &mut Value, effort: Effort) -> bool {
109 let Some(obj) = doc.as_object_mut() else {
110 return false;
111 };
112 let model = obj.get("model").and_then(Value::as_str).unwrap_or_default();
113 if !openai_supports_effort(model) {
114 return false;
115 }
116 if obj.get("reasoning").and_then(|r| r.get("effort")).is_some() {
117 return false; }
119 let reasoning = obj
120 .entry("reasoning")
121 .or_insert_with(|| Value::Object(serde_json::Map::new()));
122 let Some(map) = reasoning.as_object_mut() else {
123 return false; };
125 map.insert(
126 "effort".to_string(),
127 Value::String(openai_value(effort).to_string()),
128 );
129 record(Provider::OpenAi);
130 true
131}
132
133pub fn apply_anthropic(doc: &mut Value, effort: Effort) -> bool {
140 let Some(obj) = doc.as_object_mut() else {
141 return false;
142 };
143 let is_adaptive = obj
144 .get("thinking")
145 .and_then(|t| t.get("type"))
146 .and_then(Value::as_str)
147 == Some("adaptive");
148 if !is_adaptive {
149 return false;
150 }
151 if obj
152 .get("output_config")
153 .and_then(|o| o.get("effort"))
154 .is_some()
155 {
156 return false; }
158 let output_config = obj
159 .entry("output_config")
160 .or_insert_with(|| Value::Object(serde_json::Map::new()));
161 let Some(map) = output_config.as_object_mut() else {
162 return false;
163 };
164 map.insert(
165 "effort".to_string(),
166 Value::String(anthropic_value(effort).to_string()),
167 );
168 record(Provider::Anthropic);
169 true
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175enum GeminiStyle {
176 Level,
179 Budget,
182}
183
184fn gemini_style(model: &str) -> Option<GeminiStyle> {
191 let bare = model
192 .rsplit('/')
193 .next()
194 .unwrap_or(model)
195 .trim()
196 .to_ascii_lowercase();
197 let rest = bare.strip_prefix("gemini-")?;
198 let major: u32 = rest
199 .chars()
200 .take_while(char::is_ascii_digit)
201 .collect::<String>()
202 .parse()
203 .ok()?;
204 if major >= 3 {
205 Some(GeminiStyle::Level)
206 } else if rest.starts_with("2.5") && !rest.contains("flash-lite") {
207 Some(GeminiStyle::Budget)
208 } else {
209 None
210 }
211}
212
213fn google_level(effort: Effort) -> &'static str {
215 match effort {
216 Effort::Minimal => "minimal",
217 Effort::Low => "low",
218 Effort::Medium => "medium",
219 Effort::High => "high",
220 }
221}
222
223fn google_budget(effort: Effort) -> i64 {
228 match effort {
229 Effort::Minimal => 512,
230 Effort::Low => 4096,
231 Effort::Medium => 8192,
232 Effort::High => 24576,
233 }
234}
235
236pub fn apply_google(doc: &mut Value, effort: Effort, model: Option<&str>) -> bool {
243 let Some(style) = model.and_then(gemini_style) else {
244 return false;
245 };
246 let Some(obj) = doc.as_object_mut() else {
247 return false;
248 };
249 let gen_cfg = obj
250 .entry("generationConfig")
251 .or_insert_with(|| Value::Object(serde_json::Map::new()));
252 let Some(gen_cfg) = gen_cfg.as_object_mut() else {
253 return false; };
255 let tc = gen_cfg
256 .entry("thinkingConfig")
257 .or_insert_with(|| Value::Object(serde_json::Map::new()));
258 let Some(tc) = tc.as_object_mut() else {
259 return false;
260 };
261 if tc.contains_key("thinkingLevel") || tc.contains_key("thinkingBudget") {
262 return false; }
264 match style {
265 GeminiStyle::Level => {
266 tc.insert(
267 "thinkingLevel".to_string(),
268 Value::String(google_level(effort).to_string()),
269 );
270 }
271 GeminiStyle::Budget => {
272 tc.insert(
273 "thinkingBudget".to_string(),
274 Value::Number(google_budget(effort).into()),
275 );
276 }
277 }
278 record(Provider::Google);
279 true
280}
281
282#[derive(Debug, Clone, Copy)]
286enum Provider {
287 OpenAi,
288 Anthropic,
289 Google,
290}
291
292static OPENAI_STEERED: AtomicU64 = AtomicU64::new(0);
293static ANTHROPIC_STEERED: AtomicU64 = AtomicU64::new(0);
294static GOOGLE_STEERED: AtomicU64 = AtomicU64::new(0);
295
296fn record(provider: Provider) {
297 match provider {
298 Provider::OpenAi => &OPENAI_STEERED,
299 Provider::Anthropic => &ANTHROPIC_STEERED,
300 Provider::Google => &GOOGLE_STEERED,
301 }
302 .fetch_add(1, Ordering::Relaxed);
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
311pub struct EffortStats {
312 pub mode: String,
314 pub openai_steered: u64,
316 pub anthropic_steered: u64,
318 pub google_steered: u64,
320}
321
322#[must_use]
325pub fn snapshot(active: Option<Effort>) -> EffortStats {
326 EffortStats {
327 mode: active.map_or("off", Effort::label).to_string(),
328 openai_steered: OPENAI_STEERED.load(Ordering::Relaxed),
329 anthropic_steered: ANTHROPIC_STEERED.load(Ordering::Relaxed),
330 google_steered: GOOGLE_STEERED.load(Ordering::Relaxed),
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn openai_support_detection() {
340 for m in [
342 "gpt-5",
343 "gpt-5.5",
344 "gpt-5.4",
345 "gpt-5-codex",
346 "gpt-5.1-codex-max",
347 "o1",
348 "o1-mini",
349 "o3",
350 "o3-mini",
351 "o4-mini",
352 "openai/gpt-5.5", "openrouter/openai/o3", ] {
355 assert!(
356 openai_supports_effort(m),
357 "{m} must support reasoning effort"
358 );
359 }
360 for m in [
362 "gpt-4o",
363 "gpt-4.1",
364 "gpt-4-turbo",
365 "gpt-3.5-turbo",
366 "gpt-5-chat-latest", "gpt-5.1-chat-latest",
368 "",
369 " ",
370 ] {
371 assert!(
372 !openai_supports_effort(m),
373 "{m:?} must NOT support reasoning effort"
374 );
375 }
376 }
377
378 #[test]
379 fn openai_chat_sets_effort_on_reasoning_model() {
380 let mut doc = serde_json::json!({"model": "gpt-5.5", "messages": []});
381 assert!(apply_openai_chat(&mut doc, Effort::Low));
382 assert_eq!(doc["reasoning_effort"], "low");
383 }
384
385 #[test]
386 fn openai_chat_respects_client_value() {
387 let mut doc = serde_json::json!({
388 "model": "gpt-5.5", "reasoning_effort": "high", "messages": []
389 });
390 assert!(
391 !apply_openai_chat(&mut doc, Effort::Low),
392 "a client-set reasoning_effort must never be overridden"
393 );
394 assert_eq!(doc["reasoning_effort"], "high");
395 }
396
397 #[test]
398 fn openai_chat_skips_non_reasoning_model() {
399 let mut doc = serde_json::json!({"model": "gpt-4o", "messages": []});
400 assert!(
401 !apply_openai_chat(&mut doc, Effort::Low),
402 "a non-reasoning model must be skipped (would 400)"
403 );
404 assert!(doc.get("reasoning_effort").is_none());
405 }
406
407 #[test]
408 fn openai_responses_sets_nested_effort_and_preserves_siblings() {
409 let mut doc = serde_json::json!({
410 "model": "gpt-5.5",
411 "reasoning": {"summary": "auto"},
412 "input": []
413 });
414 assert!(apply_openai_responses(&mut doc, Effort::Medium));
415 assert_eq!(doc["reasoning"]["effort"], "medium");
416 assert_eq!(
417 doc["reasoning"]["summary"], "auto",
418 "existing reasoning.* fields must be preserved"
419 );
420 }
421
422 #[test]
423 fn openai_responses_creates_reasoning_object_when_absent() {
424 let mut doc = serde_json::json!({"model": "o3", "input": []});
425 assert!(apply_openai_responses(&mut doc, Effort::Minimal));
426 assert_eq!(doc["reasoning"]["effort"], "minimal");
427 }
428
429 #[test]
430 fn openai_responses_respects_client_value() {
431 let mut doc = serde_json::json!({
432 "model": "gpt-5.5", "reasoning": {"effort": "high"}, "input": []
433 });
434 assert!(!apply_openai_responses(&mut doc, Effort::Low));
435 assert_eq!(doc["reasoning"]["effort"], "high");
436 }
437
438 #[test]
439 fn anthropic_dials_existing_adaptive_request() {
440 let mut doc = serde_json::json!({
441 "model": "claude-opus-4-8",
442 "thinking": {"type": "adaptive"},
443 "messages": []
444 });
445 assert!(apply_anthropic(&mut doc, Effort::Low));
446 assert_eq!(doc["output_config"]["effort"], "low");
447 }
448
449 #[test]
450 fn anthropic_minimal_collapses_to_low() {
451 let mut doc = serde_json::json!({
452 "thinking": {"type": "adaptive"}, "messages": []
453 });
454 assert!(apply_anthropic(&mut doc, Effort::Minimal));
455 assert_eq!(
456 doc["output_config"]["effort"], "low",
457 "Anthropic has no `minimal`; it must collapse onto `low`"
458 );
459 }
460
461 #[test]
462 fn anthropic_skips_when_thinking_absent() {
463 let mut doc = serde_json::json!({"model": "claude-opus-4-8", "messages": []});
465 assert!(!apply_anthropic(&mut doc, Effort::Low));
466 assert!(doc.get("output_config").is_none());
467 }
468
469 #[test]
470 fn anthropic_skips_non_adaptive_thinking() {
471 let mut doc = serde_json::json!({
474 "thinking": {"type": "enabled", "budget_tokens": 4096}, "messages": []
475 });
476 assert!(!apply_anthropic(&mut doc, Effort::Low));
477 assert!(doc.get("output_config").is_none());
478 }
479
480 #[test]
481 fn anthropic_respects_client_value() {
482 let mut doc = serde_json::json!({
483 "thinking": {"type": "adaptive"},
484 "output_config": {"effort": "high"},
485 "messages": []
486 });
487 assert!(!apply_anthropic(&mut doc, Effort::Low));
488 assert_eq!(doc["output_config"]["effort"], "high");
489 }
490
491 #[test]
492 fn gemini_style_detection() {
493 use GeminiStyle::{Budget, Level};
494 for m in [
496 "gemini-3-pro",
497 "gemini-3.5-flash",
498 "google/gemini-3-pro",
499 "gemini-4-pro",
500 ] {
501 assert_eq!(gemini_style(m), Some(Level), "{m} → Level");
502 }
503 for m in [
505 "gemini-2.5-pro",
506 "gemini-2.5-flash",
507 "openrouter/google/gemini-2.5-flash",
508 ] {
509 assert_eq!(gemini_style(m), Some(Budget), "{m} → Budget");
510 }
511 for m in [
513 "gemini-2.5-flash-lite",
514 "gemini-2.0-flash",
515 "gemini-1.5-pro",
516 "gpt-5",
517 "",
518 ] {
519 assert_eq!(gemini_style(m), None, "{m} → None");
520 }
521 }
522
523 #[test]
524 fn google_3x_sets_thinking_level() {
525 let mut doc = serde_json::json!({"contents": []});
526 assert!(apply_google(&mut doc, Effort::Medium, Some("gemini-3-pro")));
527 assert_eq!(
528 doc["generationConfig"]["thinkingConfig"]["thinkingLevel"],
529 "medium"
530 );
531 }
532
533 #[test]
534 fn google_3x_minimal_maps_directly() {
535 let mut doc = serde_json::json!({"contents": []});
536 assert!(apply_google(
537 &mut doc,
538 Effort::Minimal,
539 Some("gemini-3.5-flash")
540 ));
541 assert_eq!(
542 doc["generationConfig"]["thinkingConfig"]["thinkingLevel"],
543 "minimal"
544 );
545 }
546
547 #[test]
548 fn google_25_sets_thinking_budget_in_range() {
549 let mut doc = serde_json::json!({"contents": []});
550 assert!(apply_google(&mut doc, Effort::Low, Some("gemini-2.5-pro")));
551 assert_eq!(
552 doc["generationConfig"]["thinkingConfig"]["thinkingBudget"],
553 4096
554 );
555 }
556
557 #[test]
558 fn google_preserves_existing_generation_config() {
559 let mut doc = serde_json::json!({
560 "contents": [],
561 "generationConfig": {"temperature": 0.2}
562 });
563 assert!(apply_google(&mut doc, Effort::High, Some("gemini-3-pro")));
564 assert_eq!(doc["generationConfig"]["temperature"], 0.2);
565 assert_eq!(
566 doc["generationConfig"]["thinkingConfig"]["thinkingLevel"],
567 "high"
568 );
569 }
570
571 #[test]
572 fn google_skips_flash_lite_to_avoid_enabling_thinking() {
573 let mut doc = serde_json::json!({"contents": []});
576 assert!(!apply_google(
577 &mut doc,
578 Effort::Low,
579 Some("gemini-2.5-flash-lite")
580 ));
581 assert!(doc.get("generationConfig").is_none());
582 }
583
584 #[test]
585 fn google_skips_unknown_model_and_missing_model() {
586 let mut a = serde_json::json!({"contents": []});
587 assert!(!apply_google(&mut a, Effort::Low, Some("gemini-2.0-flash")));
588 assert!(a.get("generationConfig").is_none());
589 let mut b = serde_json::json!({"contents": []});
590 assert!(!apply_google(&mut b, Effort::Low, None));
591 assert!(b.get("generationConfig").is_none());
592 }
593
594 #[test]
595 fn google_respects_client_thinking_level() {
596 let mut doc = serde_json::json!({
597 "contents": [],
598 "generationConfig": {"thinkingConfig": {"thinkingLevel": "high"}}
599 });
600 assert!(!apply_google(&mut doc, Effort::Low, Some("gemini-3-pro")));
601 assert_eq!(
602 doc["generationConfig"]["thinkingConfig"]["thinkingLevel"],
603 "high"
604 );
605 }
606
607 #[test]
608 fn google_never_sends_both_fields() {
609 let mut doc = serde_json::json!({
612 "contents": [],
613 "generationConfig": {"thinkingConfig": {"thinkingBudget": 1024}}
614 });
615 assert!(!apply_google(&mut doc, Effort::Low, Some("gemini-3-pro")));
616 assert!(
617 doc["generationConfig"]["thinkingConfig"]
618 .get("thinkingLevel")
619 .is_none()
620 );
621 }
622
623 #[test]
624 fn google_is_deterministic_across_turns() {
625 let mk = || serde_json::json!({"contents": []});
626 let (mut a, mut b) = (mk(), mk());
627 apply_google(&mut a, Effort::Medium, Some("gemini-3-pro"));
628 apply_google(&mut b, Effort::Medium, Some("gemini-3-pro"));
629 assert_eq!(
630 serde_json::to_vec(&a).unwrap(),
631 serde_json::to_vec(&b).unwrap()
632 );
633 }
634
635 #[test]
636 fn snapshot_reports_active_mode() {
637 assert_eq!(snapshot(None).mode, "off");
640 assert_eq!(snapshot(Some(Effort::Minimal)).mode, "minimal");
641 assert_eq!(snapshot(Some(Effort::High)).mode, "high");
642 }
643
644 #[test]
645 fn appliers_are_deterministic_across_turns() {
646 let mk_chat = || serde_json::json!({"model": "gpt-5.5", "messages": []});
649 let (mut a, mut b) = (mk_chat(), mk_chat());
650 apply_openai_chat(&mut a, Effort::Low);
651 apply_openai_chat(&mut b, Effort::Low);
652 assert_eq!(
653 serde_json::to_vec(&a).unwrap(),
654 serde_json::to_vec(&b).unwrap()
655 );
656
657 let mk_anthropic = || serde_json::json!({"thinking": {"type": "adaptive"}, "messages": []});
658 let (mut c, mut d) = (mk_anthropic(), mk_anthropic());
659 apply_anthropic(&mut c, Effort::Medium);
660 apply_anthropic(&mut d, Effort::Medium);
661 assert_eq!(
662 serde_json::to_vec(&c).unwrap(),
663 serde_json::to_vec(&d).unwrap()
664 );
665 }
666}