Skip to main content

lean_ctx_proxy/
effort.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Cache-safe, cross-provider reasoning-effort control (#834).
3//!
4//! Operators pin a single reasoning-effort level (`proxy.effort`) that lean-ctx
5//! translates to each provider's native parameter. Unlike per-turn "effort
6//! routing" — which changes the effort between turns of one conversation and
7//! thereby invalidates the provider prompt cache (OpenAI lists "changes to
8//! reasoning effort" as a cache-invalidation cause; Anthropic breaks message
9//! cache breakpoints on thinking-mode/config changes) — this value is a
10//! *constant*: identical on every request, so the cached prefix stays
11//! byte-stable (#448/#498) and only the model's reasoning depth changes.
12//!
13//! Safety rules, enforced by every applier:
14//! - **Opt-in:** the caller only invokes an applier when `proxy.effort` is set;
15//!   off is a strict no-op that preserves the byte-unchanged meter-only path.
16//! - **Never override the client:** an effort the client set explicitly is left
17//!   untouched, so the request keeps the client's own cache key.
18//! - **Never enable reasoning the client didn't ask for:** the Anthropic applier
19//!   only dials an *existing* adaptive request, so it never adds thinking tokens
20//!   (or a 400) where the client wanted none. OpenAI reasoning models always
21//!   reason, so setting the level only ever caps/redirects existing reasoning.
22//!   The Gemini applier excludes 2.5 *flash-lite* (thinking off by default) for
23//!   the same reason, and never sends both `thinkingLevel` and `thinkingBudget`.
24//! - **Model-gated:** models that would reject the parameter are skipped, so the
25//!   feature can never turn a working request into a 400. Gemini's generation is
26//!   read from the URL path (`thinkingLevel` on 3.x, `thinkingBudget` on 2.5).
27//! - **Deterministic:** the rewrite is a pure function of `(document, level)`, so
28//!   identical requests stay byte-identical across turns.
29
30use std::sync::atomic::{AtomicU64, Ordering};
31
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35use lean_ctx::core::config::Effort;
36
37/// OpenAI wire vocabulary for an [`Effort`] (`reasoning_effort` /
38/// `reasoning.effort`). The gpt-5 / o-series accept `minimal|low|medium|high`.
39fn 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
48/// Anthropic adaptive-thinking effort (`output_config.effort`). Anthropic has no
49/// `minimal` level, so it collapses onto `low` (its lowest-thinking level).
50fn 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/// Whether an OpenAI model accepts a reasoning-effort parameter. Reasoning
59/// models (the o-series and the gpt-5/gpt-6 families, including any `codex`
60/// build) do; the non-reasoning `gpt-4*`/`gpt-3*` models and the
61/// `*-chat-latest` non-reasoning variants reject it with a 400, so they are
62/// excluded. A vendor-prefixed name from an OpenAI-compatible gateway
63/// (`openai/gpt-5.5`, `openrouter/openai/o3`) is reduced to its bare model
64/// segment first.
65#[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
84/// Set `reasoning_effort` on an OpenAI **Chat Completions** request. No-op when
85/// the client already set it or the model is non-reasoning. Returns whether the
86/// document changed.
87pub 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; // respect the client's explicit value
93    }
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
106/// Set `reasoning.effort` on an OpenAI **Responses** request (nested object).
107/// No-op when the client already pinned `reasoning.effort` or the model is
108/// non-reasoning. Any other `reasoning.*` fields (e.g. `summary`) are preserved.
109pub 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; // respect the client's explicit value
119    }
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; // client sent a non-object `reasoning` — leave it alone
125    };
126    map.insert(
127        "effort".to_string(),
128        Value::String(openai_value(effort).to_string()),
129    );
130    record(Provider::OpenAi);
131    true
132}
133
134/// Set `output_config.effort` on an Anthropic request, but **only** when the
135/// client already requested adaptive thinking (`thinking.type == "adaptive"`).
136/// That guard means lean-ctx never enables thinking the client didn't ask for
137/// (no surprise reasoning cost) and never sends adaptive config to a model that
138/// rejects it (the client already proved the model supports it). No-op when the
139/// client already set `output_config.effort`.
140pub 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; // respect the client's explicit value
158    }
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/// Gemini's two mutually-exclusive thinking controls. Sending both in one
174/// request is a 400, so each applier path sets exactly one.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum GeminiStyle {
177    /// `generationConfig.thinkingConfig.thinkingLevel` — Gemini 3.x and later.
178    /// A string enum that maps 1:1 onto [`Effort`].
179    Level,
180    /// `generationConfig.thinkingConfig.thinkingBudget` — Gemini 2.5 (pro/flash).
181    /// An integer token budget.
182    Budget,
183}
184
185/// Map a Gemini model name (from the request URL path) to its thinking-control
186/// style. `3.x`+ → `thinkingLevel` (the go-forward API, also used by future
187/// majors); `2.5` pro/flash → `thinkingBudget`. Everything else returns `None`
188/// so the applier is a safe no-op: 2.5 *flash-lite* (thinking off by default, so
189/// a budget would switch on reasoning the client never asked for), `2.0`/`1.5`
190/// (no comparable control), and any unknown name (never risk a 400).
191fn 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
214/// Gemini 3.x `thinkingLevel` for an [`Effort`] — a 1:1 enum mapping.
215fn 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
224/// Gemini 2.5 `thinkingBudget` (thinking tokens) for an [`Effort`]. Every value
225/// lies in `[512, 24576]`, which is valid for both 2.5 *pro* (128–32768) and
226/// *flash* (1–24576), so the applier can never send an out-of-range budget that
227/// 400s. A constant per level keeps the request prefix byte-stable across turns.
228fn 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
237/// Set the Gemini thinking control that matches `model`'s generation on a
238/// `generateContent` request (`thinkingLevel` for 3.x, `thinkingBudget` for
239/// 2.5). `model` comes from the request URL path — Gemini carries it there, not
240/// in the body — threaded in by the Google handler. No-op when the model is
241/// unknown/excluded, when the client already pinned either thinking field (never
242/// override, and never end up with both → 400), or on an unexpected body shape.
243pub 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; // client sent a non-object generationConfig — leave it alone
255    };
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; // respect the client's value; never send both fields
264    }
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// --- Telemetry -------------------------------------------------------------
284
285/// Provider whose request had an effort level applied.
286#[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/// Point-in-time view of the effort control for `/status`: the active level
307/// (so an operator can confirm `proxy.effort` is live) plus how many requests
308/// have been steered per provider. Pair the counters with the per-model
309/// `reasoning_tokens` the usage meter already records to see the realized
310/// output-token savings.
311#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
312pub struct EffortStats {
313    /// Active level: `off` (no-op) or `minimal|low|medium|high`.
314    pub mode: String,
315    /// OpenAI (Chat + Responses) requests steered, cumulative.
316    pub openai_steered: u64,
317    /// Anthropic requests steered, cumulative.
318    pub anthropic_steered: u64,
319    /// Gemini requests steered, cumulative.
320    pub google_steered: u64,
321}
322
323/// Snapshot the counters together with the currently resolved effort level
324/// (`None` → `"off"`).
325#[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        // Reasoning models accept the parameter…
342        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",       // vendor-prefixed (OpenRouter etc.)
354            "openrouter/openai/o3", // doubly-prefixed
355        ] {
356            assert!(
357                openai_supports_effort(m),
358                "{m} must support reasoning effort"
359            );
360        }
361        // …non-reasoning models (and chat variants) reject it.
362        for m in [
363            "gpt-4o",
364            "gpt-4.1",
365            "gpt-4-turbo",
366            "gpt-3.5-turbo",
367            "gpt-5-chat-latest", // the non-reasoning chat variant
368            "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        // The crucial guard: never enable thinking the client didn't ask for.
465        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        // Legacy `enabled` thinking is not adaptive → don't add output_config
473        // (output_config.effort only pairs with adaptive thinking).
474        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        // 3.x and later → thinkingLevel (incl. vendor-prefixed + future majors).
496        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        // 2.5 pro/flash → thinkingBudget.
505        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        // Excluded: flash-lite (thinking off by default), older gens, unknowns.
513        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        // flash-lite has thinking OFF by default — adding a budget would switch on
575        // reasoning the client never asked for.
576        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        // Client pinned a (legacy) budget on a 3.x model → adding thinkingLevel
611        // would 400. The applier must bail.
612        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        // Counters are process-global (other tests mutate them), so assert only
639        // on the self-describing `mode` field that /status exposes.
640        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        // #498/#448: the same request + level must yield byte-identical output,
648        // so a constant effort never perturbs the provider cache prefix.
649        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}