1use agent_sdk_foundation::llm::{Effort, ThinkingConfig, ThinkingMode};
10use anyhow::{Result, bail};
11use serde::Serialize;
12
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16#[non_exhaustive]
17pub enum OpenAIReasoningEffort {
18 None,
20 Minimal,
22 Low,
24 #[default]
26 Medium,
27 High,
29 XHigh,
31 Max,
33}
34
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "lowercase")]
38#[non_exhaustive]
39pub enum OpenAIReasoningMode {
40 #[default]
42 Standard,
43 Pro,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum OpenAIReasoningContext {
52 #[default]
54 Auto,
55 CurrentTurn,
57 AllTurns,
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
63#[serde(rename_all = "lowercase")]
64#[non_exhaustive]
65pub enum OpenAIReasoningSummary {
66 #[default]
68 Auto,
69 Concise,
71 Detailed,
73}
74
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "lowercase")]
78#[non_exhaustive]
79pub enum OpenAITextVerbosity {
80 Low,
82 #[default]
84 Medium,
85 High,
87}
88
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum OpenAIApiSurface {
93 #[default]
95 Auto,
96 ChatCompletions,
98 Responses,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "lowercase")]
105#[non_exhaustive]
106pub enum OpenAIPromptCacheMode {
107 #[default]
109 Implicit,
110 Explicit,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
116#[non_exhaustive]
117pub enum OpenAIPromptCacheTtl {
118 #[serde(rename = "30m")]
120 ThirtyMinutes,
121}
122
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
125#[serde(rename_all = "lowercase")]
126#[non_exhaustive]
127pub enum OpenAIAllowedToolsMode {
128 #[default]
130 Auto,
131 Required,
133}
134
135#[derive(Clone, Debug, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum OpenAIToolChoice {
139 None,
141 Auto,
143 Required,
145 Function(String),
147 AllowedTools {
149 mode: OpenAIAllowedToolsMode,
150 tools: Vec<String>,
151 },
152}
153
154#[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 #[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 #[must_use]
196 pub const fn with_effort(mut self, effort: OpenAIReasoningEffort) -> Self {
197 self.effort = Some(effort);
198 self
199 }
200
201 #[must_use]
203 pub const fn with_mode(mut self, mode: OpenAIReasoningMode) -> Self {
204 self.mode = Some(mode);
205 self
206 }
207
208 #[must_use]
210 pub const fn with_context(mut self, context: OpenAIReasoningContext) -> Self {
211 self.context = Some(context);
212 self
213 }
214
215 #[must_use]
217 pub const fn with_summary(mut self, summary: OpenAIReasoningSummary) -> Self {
218 self.summary = Some(summary);
219 self
220 }
221
222 #[must_use]
224 pub const fn with_verbosity(mut self, verbosity: OpenAITextVerbosity) -> Self {
225 self.verbosity = Some(verbosity);
226 self
227 }
228
229 #[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 #[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 #[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 #[must_use]
255 pub const fn with_store(mut self, store: bool) -> Self {
256 self.store = Some(store);
257 self
258 }
259
260 #[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 #[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 #[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 #[must_use]
283 pub const fn effort(&self) -> Option<OpenAIReasoningEffort> {
284 self.effort
285 }
286
287 #[must_use]
289 pub const fn mode(&self) -> Option<OpenAIReasoningMode> {
290 self.mode
291 }
292
293 #[must_use]
295 pub const fn context(&self) -> Option<OpenAIReasoningContext> {
296 self.context
297 }
298
299 #[must_use]
301 pub const fn summary(&self) -> Option<OpenAIReasoningSummary> {
302 self.summary
303 }
304
305 #[must_use]
307 pub const fn verbosity(&self) -> Option<OpenAITextVerbosity> {
308 self.verbosity
309 }
310
311 #[must_use]
313 pub const fn api_surface(&self) -> OpenAIApiSurface {
314 self.api_surface
315 }
316
317 #[must_use]
319 pub const fn prompt_cache_mode(&self) -> Option<OpenAIPromptCacheMode> {
320 self.prompt_cache_mode
321 }
322
323 #[must_use]
325 pub const fn prompt_cache_ttl(&self) -> Option<OpenAIPromptCacheTtl> {
326 self.prompt_cache_ttl
327 }
328
329 #[must_use]
331 pub const fn store(&self) -> Option<bool> {
332 self.store
333 }
334
335 #[must_use]
337 pub const fn parallel_tool_calls(&self) -> Option<bool> {
338 self.parallel_tool_calls
339 }
340
341 #[must_use]
343 pub const fn tool_choice(&self) -> Option<&OpenAIToolChoice> {
344 self.tool_choice.as_ref()
345 }
346
347 #[must_use]
349 pub fn safety_identifier(&self) -> Option<&str> {
350 self.safety_identifier.as_deref()
351 }
352
353 #[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 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}