Skip to main content

agent_sdk_providers/impls/
openai_reasoning.rs

1//! Exact `OpenAI` request controls that do not fit the provider-neutral
2//! [`ThinkingConfig`] abstraction.
3//!
4//! The shared abstraction predates `OpenAI`'s distinct `none`, `xhigh`, and
5//! `max` efforts as well as GPT-5.6 reasoning modes and persisted reasoning
6//! context. These additive types preserve those wire values without changing
7//! the public foundation structs used by every provider.
8
9use agent_sdk_foundation::llm::{Effort, ThinkingConfig, ThinkingMode};
10use anyhow::{Result, bail};
11use serde::Serialize;
12
13/// Exact reasoning effort accepted by `OpenAI` APIs.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16#[non_exhaustive]
17pub enum OpenAIReasoningEffort {
18    /// Disable reasoning for latency-sensitive work.
19    None,
20    /// Use the smallest non-zero reasoning budget on models that support it.
21    Minimal,
22    /// Favor latency and token efficiency while retaining reasoning.
23    Low,
24    /// Balance reasoning quality, latency, and token usage.
25    #[default]
26    Medium,
27    /// Spend more effort on difficult reasoning tasks.
28    High,
29    /// Use extra-high reasoning effort.
30    XHigh,
31    /// Use GPT-5.6's maximum reasoning effort.
32    Max,
33}
34
35/// GPT-5.6 reasoning execution mode.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "lowercase")]
38#[non_exhaustive]
39pub enum OpenAIReasoningMode {
40    /// Standard execution mode.
41    #[default]
42    Standard,
43    /// Quality-first mode that performs additional model work.
44    Pro,
45}
46
47/// Which persisted reasoning items a Responses request may reuse.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum OpenAIReasoningContext {
52    /// Use the selected model's default context policy.
53    #[default]
54    Auto,
55    /// Make only reasoning from the current turn available.
56    CurrentTurn,
57    /// Reuse compatible reasoning items from earlier turns.
58    AllTurns,
59}
60
61/// Requested detail level for the model's user-visible reasoning summary.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "lowercase")]
64#[non_exhaustive]
65pub enum OpenAIReasoningSummary {
66    /// Use the most detailed summary supported by the selected model.
67    #[default]
68    Auto,
69    /// Request a concise reasoning summary.
70    Concise,
71    /// Request a detailed reasoning summary.
72    Detailed,
73}
74
75/// Desired verbosity of the final text response.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "lowercase")]
78#[non_exhaustive]
79pub enum OpenAITextVerbosity {
80    /// Prefer concise final text.
81    Low,
82    /// Use the model's balanced verbosity.
83    #[default]
84    Medium,
85    /// Prefer a more detailed final response.
86    High,
87}
88
89/// Select the `OpenAI` API surface used by [`OpenAIProvider`](super::openai::OpenAIProvider).
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum OpenAIApiSurface {
93    /// Let the provider choose the best surface for the model and request.
94    #[default]
95    Auto,
96    /// Prefer Chat Completions when the selected controls are supported there.
97    ChatCompletions,
98    /// Force the Responses API.
99    Responses,
100}
101
102/// Controls whether GPT-5.6 creates an automatic prompt-cache breakpoint.
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "lowercase")]
105#[non_exhaustive]
106pub enum OpenAIPromptCacheMode {
107    /// Let `OpenAI` create an implicit cache breakpoint.
108    #[default]
109    Implicit,
110    /// Cache only caller-provided explicit breakpoints.
111    Explicit,
112}
113
114/// Exact GPT-5.6 prompt-cache retention window.
115#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
116#[non_exhaustive]
117pub enum OpenAIPromptCacheTtl {
118    /// Retain the cached prefix for 30 minutes.
119    #[serde(rename = "30m")]
120    ThirtyMinutes,
121}
122
123/// Whether an allowed-tools policy may call zero tools or must call at least one.
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
125#[serde(rename_all = "lowercase")]
126#[non_exhaustive]
127pub enum OpenAIAllowedToolsMode {
128    /// Let the model decide whether to call one of the allowed tools.
129    #[default]
130    Auto,
131    /// Require at least one call from the allowed subset.
132    Required,
133}
134
135/// Complete `OpenAI` function-tool selection policy.
136#[derive(Clone, Debug, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum OpenAIToolChoice {
139    /// Do not call a function tool.
140    None,
141    /// Let the model choose whether and which function tools to call.
142    Auto,
143    /// Require one or more function calls.
144    Required,
145    /// Force one named function.
146    Function(String),
147    /// Restrict calls to a stable subset without changing the full tool list.
148    AllowedTools {
149        mode: OpenAIAllowedToolsMode,
150        tools: Vec<String>,
151    },
152}
153
154/// Exact `OpenAI` reasoning and closely related response controls.
155///
156/// Every field is optional. [`Self::new`] therefore preserves the model's
157/// server-side defaults until a builder is called.
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct OpenAIReasoningConfig {
160    effort: Option<OpenAIReasoningEffort>,
161    mode: Option<OpenAIReasoningMode>,
162    context: Option<OpenAIReasoningContext>,
163    summary: Option<OpenAIReasoningSummary>,
164    verbosity: Option<OpenAITextVerbosity>,
165    api_surface: OpenAIApiSurface,
166    prompt_cache_mode: Option<OpenAIPromptCacheMode>,
167    prompt_cache_ttl: Option<OpenAIPromptCacheTtl>,
168    store: Option<bool>,
169    parallel_tool_calls: Option<bool>,
170    tool_choice: Option<OpenAIToolChoice>,
171    safety_identifier: Option<String>,
172}
173
174impl OpenAIReasoningConfig {
175    /// Create a config that preserves every model/API default.
176    #[must_use]
177    pub const fn new() -> Self {
178        Self {
179            effort: None,
180            mode: None,
181            context: None,
182            summary: None,
183            verbosity: None,
184            api_surface: OpenAIApiSurface::Auto,
185            prompt_cache_mode: None,
186            prompt_cache_ttl: None,
187            store: None,
188            parallel_tool_calls: None,
189            tool_choice: None,
190            safety_identifier: None,
191        }
192    }
193
194    /// Set the exact reasoning effort.
195    #[must_use]
196    pub const fn with_effort(mut self, effort: OpenAIReasoningEffort) -> Self {
197        self.effort = Some(effort);
198        self
199    }
200
201    /// Set the GPT-5.6 reasoning execution mode.
202    #[must_use]
203    pub const fn with_mode(mut self, mode: OpenAIReasoningMode) -> Self {
204        self.mode = Some(mode);
205        self
206    }
207
208    /// Select which persisted reasoning context the Responses API may reuse.
209    #[must_use]
210    pub const fn with_context(mut self, context: OpenAIReasoningContext) -> Self {
211        self.context = Some(context);
212        self
213    }
214
215    /// Request a user-visible reasoning summary.
216    #[must_use]
217    pub const fn with_summary(mut self, summary: OpenAIReasoningSummary) -> Self {
218        self.summary = Some(summary);
219        self
220    }
221
222    /// Set final-answer verbosity.
223    #[must_use]
224    pub const fn with_verbosity(mut self, verbosity: OpenAITextVerbosity) -> Self {
225        self.verbosity = Some(verbosity);
226        self
227    }
228
229    /// Select the API surface used by the general `OpenAI` provider.
230    #[must_use]
231    pub const fn with_api_surface(mut self, api_surface: OpenAIApiSurface) -> Self {
232        self.api_surface = api_surface;
233        self
234    }
235
236    /// Select implicit or explicit prompt-cache breakpoint creation.
237    #[must_use]
238    pub const fn with_prompt_cache_mode(
239        mut self,
240        prompt_cache_mode: OpenAIPromptCacheMode,
241    ) -> Self {
242        self.prompt_cache_mode = Some(prompt_cache_mode);
243        self
244    }
245
246    /// Set GPT-5.6's exact prompt-cache retention window.
247    #[must_use]
248    pub const fn with_prompt_cache_ttl(mut self, prompt_cache_ttl: OpenAIPromptCacheTtl) -> Self {
249        self.prompt_cache_ttl = Some(prompt_cache_ttl);
250        self
251    }
252
253    /// Select whether `OpenAI` stores the response as application state.
254    #[must_use]
255    pub const fn with_store(mut self, store: bool) -> Self {
256        self.store = Some(store);
257        self
258    }
259
260    /// Enable or disable parallel function calls.
261    #[must_use]
262    pub const fn with_parallel_tool_calls(mut self, enabled: bool) -> Self {
263        self.parallel_tool_calls = Some(enabled);
264        self
265    }
266
267    /// Set the complete `OpenAI` function-tool choice policy.
268    #[must_use]
269    pub fn with_tool_choice(mut self, tool_choice: OpenAIToolChoice) -> Self {
270        self.tool_choice = Some(tool_choice);
271        self
272    }
273
274    /// Set a stable, privacy-preserving identifier for end-user safety controls.
275    #[must_use]
276    pub fn with_safety_identifier(mut self, safety_identifier: impl Into<String>) -> Self {
277        self.safety_identifier = Some(safety_identifier.into());
278        self
279    }
280
281    /// Configured exact reasoning effort, if any.
282    #[must_use]
283    pub const fn effort(&self) -> Option<OpenAIReasoningEffort> {
284        self.effort
285    }
286
287    /// Configured reasoning execution mode, if any.
288    #[must_use]
289    pub const fn mode(&self) -> Option<OpenAIReasoningMode> {
290        self.mode
291    }
292
293    /// Configured persisted-reasoning context policy, if any.
294    #[must_use]
295    pub const fn context(&self) -> Option<OpenAIReasoningContext> {
296        self.context
297    }
298
299    /// Configured reasoning summary detail, if any.
300    #[must_use]
301    pub const fn summary(&self) -> Option<OpenAIReasoningSummary> {
302        self.summary
303    }
304
305    /// Configured final-answer verbosity, if any.
306    #[must_use]
307    pub const fn verbosity(&self) -> Option<OpenAITextVerbosity> {
308        self.verbosity
309    }
310
311    /// Selected API surface.
312    #[must_use]
313    pub const fn api_surface(&self) -> OpenAIApiSurface {
314        self.api_surface
315    }
316
317    /// Configured prompt-cache mode, if any.
318    #[must_use]
319    pub const fn prompt_cache_mode(&self) -> Option<OpenAIPromptCacheMode> {
320        self.prompt_cache_mode
321    }
322
323    /// Configured exact prompt-cache TTL, if any.
324    #[must_use]
325    pub const fn prompt_cache_ttl(&self) -> Option<OpenAIPromptCacheTtl> {
326        self.prompt_cache_ttl
327    }
328
329    /// Explicit response-storage preference, if configured.
330    #[must_use]
331    pub const fn store(&self) -> Option<bool> {
332        self.store
333    }
334
335    /// Explicit parallel-tool preference, if configured.
336    #[must_use]
337    pub const fn parallel_tool_calls(&self) -> Option<bool> {
338        self.parallel_tool_calls
339    }
340
341    /// Provider-owned tool-choice policy, if configured.
342    #[must_use]
343    pub const fn tool_choice(&self) -> Option<&OpenAIToolChoice> {
344        self.tool_choice.as_ref()
345    }
346
347    /// Stable safety identifier, if configured.
348    #[must_use]
349    pub fn safety_identifier(&self) -> Option<&str> {
350        self.safety_identifier.as_deref()
351    }
352
353    /// Whether this config uses controls that only the Responses API accepts.
354    #[must_use]
355    pub const fn requires_responses_api(&self) -> bool {
356        matches!(self.api_surface, OpenAIApiSurface::Responses)
357            || self.mode.is_some()
358            || self.context.is_some()
359            || self.summary.is_some()
360    }
361
362    pub(crate) const fn with_optional_effort(
363        mut self,
364        effort: Option<OpenAIReasoningEffort>,
365    ) -> Self {
366        self.effort = effort;
367        self
368    }
369}
370
371pub(crate) const fn is_gpt56_model(model: &str) -> bool {
372    matches!(
373        model.as_bytes(),
374        b"gpt-5.6" | b"gpt-5.6-sol" | b"gpt-5.6-terra" | b"gpt-5.6-luna"
375    )
376}
377
378pub(crate) fn validate_reasoning_config(model: &str, config: &OpenAIReasoningConfig) -> Result<()> {
379    if let Some(effort) = config.effort() {
380        let validation = if is_gpt56_model(model) {
381            Some((
382                matches!(
383                    effort,
384                    OpenAIReasoningEffort::None
385                        | OpenAIReasoningEffort::Low
386                        | OpenAIReasoningEffort::Medium
387                        | OpenAIReasoningEffort::High
388                        | OpenAIReasoningEffort::XHigh
389                        | OpenAIReasoningEffort::Max
390                ),
391                "none, low, medium, high, xhigh, and max",
392            ))
393        } else {
394            match model {
395                "gpt-5.4" => Some((
396                    matches!(
397                        effort,
398                        OpenAIReasoningEffort::None
399                            | OpenAIReasoningEffort::Low
400                            | OpenAIReasoningEffort::Medium
401                            | OpenAIReasoningEffort::High
402                            | OpenAIReasoningEffort::XHigh
403                    ),
404                    "none, low, medium, high, and xhigh",
405                )),
406                "gpt-5.3-codex" => Some((
407                    matches!(
408                        effort,
409                        OpenAIReasoningEffort::Low
410                            | OpenAIReasoningEffort::Medium
411                            | OpenAIReasoningEffort::High
412                            | OpenAIReasoningEffort::XHigh
413                    ),
414                    "low, medium, high, and xhigh",
415                )),
416                "gpt-5.2-pro" => Some((
417                    matches!(
418                        effort,
419                        OpenAIReasoningEffort::Medium
420                            | OpenAIReasoningEffort::High
421                            | OpenAIReasoningEffort::XHigh
422                    ),
423                    "medium, high, and xhigh",
424                )),
425                "gpt-5" | "gpt-5-mini" | "gpt-5-nano" => Some((
426                    matches!(
427                        effort,
428                        OpenAIReasoningEffort::Minimal
429                            | OpenAIReasoningEffort::Low
430                            | OpenAIReasoningEffort::Medium
431                            | OpenAIReasoningEffort::High
432                    ),
433                    "minimal, low, medium, and high",
434                )),
435                _ => None,
436            }
437        };
438
439        if let Some((false, supported_names)) = validation {
440            bail!(
441                "reasoning effort is not supported for model={model}; supported efforts are {supported_names}"
442            );
443        }
444    }
445
446    if (config.prompt_cache_mode().is_some() || config.prompt_cache_ttl().is_some())
447        && !is_gpt56_model(model)
448    {
449        bail!(
450            "exact prompt-cache mode and TTL controls are only supported for GPT-5.6 models; model={model}"
451        );
452    }
453
454    if model == "gpt-5.3-codex"
455        && (config.mode().is_some() || config.context().is_some() || config.summary().is_some())
456    {
457        bail!("reasoning mode, context, and summary controls are not supported for model={model}");
458    }
459
460    Ok(())
461}
462
463pub(crate) fn validate_tool_choice(
464    config: Option<&OpenAIReasoningConfig>,
465    tools: Option<&[agent_sdk_foundation::llm::Tool]>,
466) -> Result<()> {
467    let Some(choice) = config.and_then(OpenAIReasoningConfig::tool_choice) else {
468        return Ok(());
469    };
470    let tools = tools.unwrap_or_default();
471
472    match choice {
473        OpenAIToolChoice::Required if tools.is_empty() => {
474            bail!("OpenAI tool_choice=required needs at least one function tool")
475        }
476        OpenAIToolChoice::Function(name) => {
477            if !tools.iter().any(|tool| tool.name == *name) {
478                bail!("OpenAI tool_choice names unknown function `{name}`");
479            }
480        }
481        OpenAIToolChoice::AllowedTools { tools: allowed, .. } => {
482            if allowed.is_empty() {
483                bail!("OpenAI allowed_tools must contain at least one function name");
484            }
485            if let Some(name) = allowed
486                .iter()
487                .find(|name| !tools.iter().any(|tool| tool.name == name.as_str()))
488            {
489                bail!("OpenAI allowed_tools names unknown function `{name}`");
490            }
491        }
492        OpenAIToolChoice::None | OpenAIToolChoice::Auto | OpenAIToolChoice::Required => {}
493    }
494
495    Ok(())
496}
497
498pub(crate) const fn legacy_reasoning_effort(
499    config: &ThinkingConfig,
500) -> Option<OpenAIReasoningEffort> {
501    if let Some(effort) = config.effort {
502        return Some(match effort {
503            Effort::Low => OpenAIReasoningEffort::Low,
504            Effort::Medium => OpenAIReasoningEffort::Medium,
505            Effort::High => OpenAIReasoningEffort::High,
506            // Preserve the historical OpenAI mapping. Exact GPT-5.6 `max`
507            // is available through `OpenAIReasoningConfig`.
508            Effort::Max => OpenAIReasoningEffort::XHigh,
509        });
510    }
511
512    match &config.mode {
513        ThinkingMode::Adaptive => None,
514        ThinkingMode::Enabled { budget_tokens } => Some(if *budget_tokens <= 4_096 {
515            OpenAIReasoningEffort::Low
516        } else if *budget_tokens <= 16_384 {
517            OpenAIReasoningEffort::Medium
518        } else if *budget_tokens <= 32_768 {
519            OpenAIReasoningEffort::High
520        } else {
521            OpenAIReasoningEffort::XHigh
522        }),
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn exact_efforts_serialize_without_collapsing_xhigh_and_max() -> anyhow::Result<()> {
532        for (effort, expected) in [
533            (OpenAIReasoningEffort::None, "\"none\""),
534            (OpenAIReasoningEffort::Minimal, "\"minimal\""),
535            (OpenAIReasoningEffort::Low, "\"low\""),
536            (OpenAIReasoningEffort::Medium, "\"medium\""),
537            (OpenAIReasoningEffort::High, "\"high\""),
538            (OpenAIReasoningEffort::XHigh, "\"xhigh\""),
539            (OpenAIReasoningEffort::Max, "\"max\""),
540        ] {
541            assert_eq!(serde_json::to_string(&effort)?, expected);
542        }
543        Ok(())
544    }
545
546    #[test]
547    fn legacy_max_remains_xhigh() {
548        let config = ThinkingConfig::adaptive_with_effort(Effort::Max);
549        assert_eq!(
550            legacy_reasoning_effort(&config),
551            Some(OpenAIReasoningEffort::XHigh)
552        );
553    }
554
555    #[test]
556    fn response_only_controls_are_detected() {
557        assert!(!OpenAIReasoningConfig::new().requires_responses_api());
558        assert!(
559            OpenAIReasoningConfig::new()
560                .with_mode(OpenAIReasoningMode::Pro)
561                .requires_responses_api()
562        );
563        assert!(
564            OpenAIReasoningConfig::new()
565                .with_mode(OpenAIReasoningMode::Standard)
566                .requires_responses_api()
567        );
568        assert!(
569            OpenAIReasoningConfig::new()
570                .with_context(OpenAIReasoningContext::AllTurns)
571                .requires_responses_api()
572        );
573        assert!(
574            OpenAIReasoningConfig::new()
575                .with_summary(OpenAIReasoningSummary::Auto)
576                .requires_responses_api()
577        );
578        assert!(
579            !OpenAIReasoningConfig::new()
580                .with_tool_choice(OpenAIToolChoice::AllowedTools {
581                    mode: OpenAIAllowedToolsMode::Auto,
582                    tools: vec!["lookup".to_owned()],
583                })
584                .requires_responses_api()
585        );
586    }
587
588    #[test]
589    fn gpt56_rejects_minimal_but_accepts_max() {
590        let minimal = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Minimal);
591        assert!(validate_reasoning_config("gpt-5.6", &minimal).is_err());
592
593        let max = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Max);
594        assert!(validate_reasoning_config("gpt-5.6", &max).is_ok());
595    }
596
597    #[test]
598    fn gpt53_codex_enforces_its_narrower_reasoning_and_cache_controls() {
599        let xhigh = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::XHigh);
600        assert!(validate_reasoning_config("gpt-5.3-codex", &xhigh).is_ok());
601
602        let max = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Max);
603        assert!(validate_reasoning_config("gpt-5.3-codex", &max).is_err());
604
605        let mode = OpenAIReasoningConfig::new().with_mode(OpenAIReasoningMode::Pro);
606        assert!(validate_reasoning_config("gpt-5.3-codex", &mode).is_err());
607
608        let cache =
609            OpenAIReasoningConfig::new().with_prompt_cache_mode(OpenAIPromptCacheMode::Explicit);
610        assert!(validate_reasoning_config("gpt-5.3-codex", &cache).is_err());
611    }
612
613    #[test]
614    fn known_model_effort_sets_reject_unsupported_values() {
615        let minimal = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Minimal);
616        let none = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::None);
617        let xhigh = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::XHigh);
618        let max = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Max);
619
620        assert!(validate_reasoning_config("gpt-5.4", &none).is_ok());
621        assert!(validate_reasoning_config("gpt-5.4", &minimal).is_err());
622        assert!(validate_reasoning_config("gpt-5.4", &max).is_err());
623
624        assert!(validate_reasoning_config("gpt-5.2-pro", &none).is_err());
625        assert!(validate_reasoning_config("gpt-5.2-pro", &minimal).is_err());
626        assert!(
627            validate_reasoning_config(
628                "gpt-5.2-pro",
629                &OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Medium),
630            )
631            .is_ok()
632        );
633        assert!(validate_reasoning_config("gpt-5.2-pro", &xhigh).is_ok());
634        assert!(validate_reasoning_config("gpt-5.2-pro", &max).is_err());
635
636        for model in ["gpt-5", "gpt-5-mini", "gpt-5-nano"] {
637            assert!(validate_reasoning_config(model, &minimal).is_ok());
638            assert!(validate_reasoning_config(model, &none).is_err());
639            assert!(validate_reasoning_config(model, &xhigh).is_err());
640            assert!(validate_reasoning_config(model, &max).is_err());
641        }
642
643        let custom_max = validate_reasoning_config("vendor/custom-reasoner", &max);
644        assert!(custom_max.is_ok());
645    }
646}