Skip to main content

lean_ctx/proxy/
effort.rs

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