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