1use crate::attachments::request_has_attachments;
26use crate::model_features::{ModelApiSurface, get_model_features};
27use crate::provider::LlmProvider;
28use crate::streaming::{SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind};
29use agent_sdk_foundation::llm::{
30 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, StopReason, ThinkingConfig,
31 ToolChoice, Usage,
32};
33use anyhow::Result;
34use async_trait::async_trait;
35use futures::StreamExt;
36use reqwest::StatusCode;
37use serde::de::Error as _;
38use serde::ser::SerializeStruct as _;
39use serde::{Deserialize, Serialize};
40use std::collections::HashMap;
41
42use super::openai_reasoning::{
43 OpenAIAllowedToolsMode, OpenAIApiSurface, OpenAIPromptCacheMode, OpenAIPromptCacheTtl,
44 OpenAIReasoningConfig, OpenAIReasoningEffort, OpenAITextVerbosity, OpenAIToolChoice,
45 is_gpt56_model, legacy_reasoning_effort, validate_reasoning_config, validate_tool_choice,
46};
47use super::openai_responses::OpenAIResponsesProvider;
48use super::openai_schema::normalize_strict_schema;
49
50const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
51const OPENAI_RESPONSES_REASONING_PROVIDER: &str = "openai-responses";
52
53fn build_http_client() -> reqwest::Client {
58 reqwest::Client::builder()
59 .connect_timeout(std::time::Duration::from_secs(30))
60 .tcp_keepalive(std::time::Duration::from_secs(30))
61 .build()
62 .unwrap_or_default()
63}
64
65fn requires_responses_api(model: &str) -> bool {
67 model == MODEL_GPT52_CODEX
68 || get_model_features(model).is_some_and(|features| {
69 features.api_surfaces.contains(&ModelApiSurface::Responses)
70 && !features
71 .api_surfaces
72 .contains(&ModelApiSurface::ChatCompletions)
73 })
74}
75
76fn is_official_openai_base_url(base_url: &str) -> bool {
77 url::Url::parse(base_url).is_ok_and(|url| url.host_str() == Some("api.openai.com"))
78}
79
80fn chat_store(base_url: &str, config: Option<&OpenAIReasoningConfig>) -> Option<bool> {
81 config
82 .and_then(OpenAIReasoningConfig::store)
83 .or_else(|| is_official_openai_base_url(base_url).then_some(false))
84}
85
86fn chat_prompt_cache_key<'a>(base_url: &str, session_id: Option<&'a str>) -> Option<&'a str> {
87 is_official_openai_base_url(base_url)
88 .then_some(session_id)
89 .flatten()
90}
91
92fn request_is_agentic(request: &ChatRequest) -> bool {
93 request
94 .tools
95 .as_ref()
96 .is_some_and(|tools| !tools.is_empty()) || request.messages.iter().any(|message| {
97 matches!(
98 &message.content,
99 Content::Blocks(blocks)
100 if blocks.iter().any(|block| {
101 matches!(block, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. })
102 })
103 )
104 })
105}
106
107fn request_has_openai_responses_history(request: &ChatRequest) -> bool {
108 request.messages.iter().any(|message| {
109 matches!(
110 &message.content,
111 Content::Blocks(blocks)
112 if blocks.iter().any(|block| {
113 matches!(
114 block,
115 ContentBlock::OpaqueReasoning { provider, .. }
116 if provider == OPENAI_RESPONSES_REASONING_PROVIDER
117 )
118 })
119 )
120 })
121}
122
123fn should_use_responses_api(
124 base_url: &str,
125 model: &str,
126 request: &ChatRequest,
127 reasoning: Option<&OpenAIReasoningConfig>,
128) -> bool {
129 let surface = reasoning.map_or(OpenAIApiSurface::Auto, OpenAIReasoningConfig::api_surface);
130 let explicitly_requests_responses = matches!(surface, OpenAIApiSurface::Responses);
131 let explicitly_requests_chat = matches!(surface, OpenAIApiSurface::ChatCompletions);
132 let response_only_reasoning = !explicitly_requests_chat
133 && reasoning.is_some_and(|config| {
134 matches!(config.api_surface(), OpenAIApiSurface::Responses)
135 || config.mode().is_some()
136 || config.context().is_some()
137 || config.summary().is_some()
138 });
139 let response_history_route =
140 !explicitly_requests_chat && request_has_openai_responses_history(request);
141 let official_auto_route = is_official_openai_base_url(base_url)
142 && !explicitly_requests_chat
143 && (is_gpt56_model(model) || request_is_agentic(request));
144
145 let attachment_route = !explicitly_requests_chat
146 && request_has_attachments(request)
147 && (is_official_openai_base_url(base_url) || explicitly_requests_responses);
148
149 requires_responses_api(model)
150 || explicitly_requests_responses
151 || response_only_reasoning
152 || response_history_route
153 || attachment_route
154 || official_auto_route
155}
156
157pub const MODEL_GPT56: &str = "gpt-5.6";
159pub const MODEL_GPT56_SOL: &str = "gpt-5.6-sol";
160pub const MODEL_GPT56_TERRA: &str = "gpt-5.6-terra";
161pub const MODEL_GPT56_LUNA: &str = "gpt-5.6-luna";
162
163pub const MODEL_GPT54: &str = "gpt-5.4";
165
166pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
168
169pub const MODEL_GPT52_INSTANT: &str = "gpt-5.2-instant";
171pub const MODEL_GPT52_THINKING: &str = "gpt-5.2-thinking";
172pub const MODEL_GPT52_PRO: &str = "gpt-5.2-pro";
173pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
174
175pub const MODEL_GPT5: &str = "gpt-5";
177pub const MODEL_GPT5_MINI: &str = "gpt-5-mini";
178pub const MODEL_GPT5_NANO: &str = "gpt-5-nano";
179
180pub const MODEL_O3: &str = "o3";
182pub const MODEL_O3_MINI: &str = "o3-mini";
183pub const MODEL_O4_MINI: &str = "o4-mini";
184pub const MODEL_O1: &str = "o1";
185pub const MODEL_O1_MINI: &str = "o1-mini";
186
187pub const MODEL_GPT41: &str = "gpt-4.1";
189pub const MODEL_GPT41_MINI: &str = "gpt-4.1-mini";
190pub const MODEL_GPT41_NANO: &str = "gpt-4.1-nano";
191
192pub const MODEL_GPT4O: &str = "gpt-4o";
194pub const MODEL_GPT4O_MINI: &str = "gpt-4o-mini";
195
196pub const BASE_URL_KIMI: &str = "https://api.moonshot.ai/v1";
198pub const BASE_URL_ZAI: &str = "https://api.z.ai/api/paas/v4";
199pub const BASE_URL_MINIMAX: &str = "https://api.minimax.io/v1";
200pub const MODEL_KIMI_K2_5: &str = "kimi-k2.5";
201pub const MODEL_KIMI_K2_THINKING: &str = "kimi-k2-thinking";
202pub const MODEL_ZAI_GLM5: &str = "glm-5";
203pub const MODEL_MINIMAX_M2_5: &str = "MiniMax-M2.5";
204
205#[derive(Clone)]
210pub struct OpenAIProvider {
211 client: reqwest::Client,
212 api_key: String,
213 model: String,
214 base_url: String,
215 thinking: Option<ThinkingConfig>,
216 reasoning: Option<OpenAIReasoningConfig>,
217 extra_headers: Vec<(String, String)>,
219}
220
221impl OpenAIProvider {
222 pub const API_KEY_ENV: &'static str = "OPENAI_API_KEY";
224
225 #[must_use]
227 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
228 Self {
229 client: build_http_client(),
230 api_key: api_key.into(),
231 model: model.into(),
232 base_url: DEFAULT_BASE_URL.to_owned(),
233 thinking: None,
234 reasoning: None,
235 extra_headers: Vec::new(),
236 }
237 }
238
239 #[must_use]
247 pub fn from_env() -> Self {
248 Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
249 }
250
251 pub fn try_from_env() -> Result<Self> {
258 let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
259 anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
260 })?;
261 Ok(Self::gpt5(api_key))
262 }
263
264 #[must_use]
266 pub fn with_base_url(
267 api_key: impl Into<String>,
268 model: impl Into<String>,
269 base_url: impl Into<String>,
270 ) -> Self {
271 Self {
272 client: build_http_client(),
273 api_key: api_key.into(),
274 model: model.into(),
275 base_url: base_url.into(),
276 thinking: None,
277 reasoning: None,
278 extra_headers: Vec::new(),
279 }
280 }
281
282 #[must_use]
284 pub fn kimi(api_key: String, model: String) -> Self {
285 Self::with_base_url(api_key, model, BASE_URL_KIMI.to_owned())
286 }
287
288 #[must_use]
290 pub fn kimi_k2_5(api_key: String) -> Self {
291 Self::kimi(api_key, MODEL_KIMI_K2_5.to_owned())
292 }
293
294 #[must_use]
296 pub fn kimi_k2_thinking(api_key: String) -> Self {
297 Self::kimi(api_key, MODEL_KIMI_K2_THINKING.to_owned())
298 }
299
300 #[must_use]
302 pub fn zai(api_key: String, model: String) -> Self {
303 Self::with_base_url(api_key, model, BASE_URL_ZAI.to_owned())
304 }
305
306 #[must_use]
308 pub fn zai_glm5(api_key: String) -> Self {
309 Self::zai(api_key, MODEL_ZAI_GLM5.to_owned())
310 }
311
312 #[must_use]
314 pub fn minimax(api_key: String, model: String) -> Self {
315 Self::with_base_url(api_key, model, BASE_URL_MINIMAX.to_owned())
316 }
317
318 #[must_use]
320 pub fn minimax_m2_5(api_key: String) -> Self {
321 Self::minimax(api_key, MODEL_MINIMAX_M2_5.to_owned())
322 }
323
324 #[must_use]
326 pub fn gpt56(api_key: String) -> Self {
327 Self::new(api_key, MODEL_GPT56.to_owned())
328 }
329
330 #[must_use]
332 pub fn gpt56_sol(api_key: String) -> Self {
333 Self::new(api_key, MODEL_GPT56_SOL.to_owned())
334 }
335
336 #[must_use]
338 pub fn gpt56_terra(api_key: String) -> Self {
339 Self::new(api_key, MODEL_GPT56_TERRA.to_owned())
340 }
341
342 #[must_use]
344 pub fn gpt56_luna(api_key: String) -> Self {
345 Self::new(api_key, MODEL_GPT56_LUNA.to_owned())
346 }
347
348 #[must_use]
350 pub fn gpt52_instant(api_key: String) -> Self {
351 Self::new(api_key, MODEL_GPT52_INSTANT.to_owned())
352 }
353
354 #[must_use]
356 pub fn gpt54(api_key: String) -> Self {
357 Self::new(api_key, MODEL_GPT54.to_owned())
358 }
359
360 #[must_use]
362 pub fn gpt53_codex(api_key: String) -> Self {
363 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
364 }
365
366 #[must_use]
368 pub fn gpt52_thinking(api_key: String) -> Self {
369 Self::new(api_key, MODEL_GPT52_THINKING.to_owned())
370 }
371
372 #[must_use]
374 pub fn gpt52_pro(api_key: String) -> Self {
375 Self::new(api_key, MODEL_GPT52_PRO.to_owned())
376 }
377
378 #[must_use]
380 pub fn codex(api_key: String) -> Self {
381 Self::gpt53_codex(api_key)
382 }
383
384 #[must_use]
386 pub fn gpt5(api_key: String) -> Self {
387 Self::new(api_key, MODEL_GPT5.to_owned())
388 }
389
390 #[must_use]
392 pub fn gpt5_mini(api_key: String) -> Self {
393 Self::new(api_key, MODEL_GPT5_MINI.to_owned())
394 }
395
396 #[must_use]
398 pub fn gpt5_nano(api_key: String) -> Self {
399 Self::new(api_key, MODEL_GPT5_NANO.to_owned())
400 }
401
402 #[must_use]
404 pub fn o3(api_key: String) -> Self {
405 Self::new(api_key, MODEL_O3.to_owned())
406 }
407
408 #[must_use]
410 pub fn o3_mini(api_key: String) -> Self {
411 Self::new(api_key, MODEL_O3_MINI.to_owned())
412 }
413
414 #[must_use]
416 pub fn o4_mini(api_key: String) -> Self {
417 Self::new(api_key, MODEL_O4_MINI.to_owned())
418 }
419
420 #[must_use]
422 pub fn o1(api_key: String) -> Self {
423 Self::new(api_key, MODEL_O1.to_owned())
424 }
425
426 #[must_use]
428 pub fn o1_mini(api_key: String) -> Self {
429 Self::new(api_key, MODEL_O1_MINI.to_owned())
430 }
431
432 #[must_use]
434 pub fn gpt41(api_key: String) -> Self {
435 Self::new(api_key, MODEL_GPT41.to_owned())
436 }
437
438 #[must_use]
440 pub fn gpt41_mini(api_key: String) -> Self {
441 Self::new(api_key, MODEL_GPT41_MINI.to_owned())
442 }
443
444 #[must_use]
446 pub fn gpt4o(api_key: String) -> Self {
447 Self::new(api_key, MODEL_GPT4O.to_owned())
448 }
449
450 #[must_use]
452 pub fn gpt4o_mini(api_key: String) -> Self {
453 Self::new(api_key, MODEL_GPT4O_MINI.to_owned())
454 }
455
456 #[must_use]
458 pub fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
459 self.thinking = Some(thinking);
460 self.reasoning = None;
461 self
462 }
463
464 #[must_use]
471 pub fn with_reasoning(mut self, reasoning: OpenAIReasoningConfig) -> Self {
472 self.reasoning = Some(reasoning);
473 self.thinking = None;
474 self
475 }
476
477 #[must_use]
479 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
480 self.extra_headers = headers;
481 self
482 }
483
484 fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
487 let builder = if self.api_key.is_empty() {
488 builder
489 } else {
490 builder.header("Authorization", format!("Bearer {}", self.api_key))
491 };
492 self.extra_headers
493 .iter()
494 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
495 }
496
497 fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
498 if request.max_tokens_explicit {
499 request.max_tokens
500 } else {
501 self.default_max_tokens()
502 }
503 }
504
505 fn resolve_openai_reasoning(
506 &self,
507 request_thinking: Option<&ThinkingConfig>,
508 ) -> Result<Option<OpenAIReasoningConfig>> {
509 let legacy = if request_thinking.is_some() || self.reasoning.is_none() {
510 self.resolve_thinking_config(request_thinking)?
511 } else {
512 None
513 };
514
515 let config = match (self.reasoning.clone(), legacy.as_ref()) {
516 (Some(config), Some(thinking)) => {
517 Some(config.with_optional_effort(legacy_reasoning_effort(thinking)))
518 }
519 (Some(config), None) => Some(config),
520 (None, Some(thinking)) => Some(
521 OpenAIReasoningConfig::new()
522 .with_optional_effort(legacy_reasoning_effort(thinking)),
523 ),
524 (None, None) => None,
525 };
526
527 if let Some(config) = &config {
528 validate_reasoning_config(&self.model, config)?;
529 }
530 Ok(config)
531 }
532
533 fn validate_chat_attachments(&self, request: &ChatRequest) -> Result<()> {
534 if request_has_attachments(request) {
535 anyhow::bail!(
536 "OpenAI Chat Completions request for model={} contains image or document attachments that this provider cannot serialize; use the Responses API",
537 self.model
538 );
539 }
540 Ok(())
541 }
542
543 fn validate_chat_opaque_reasoning(request: &ChatRequest) -> Result<()> {
544 if request_has_openai_responses_history(request) {
545 anyhow::bail!(
546 "OpenAI Responses reasoning history cannot be serialized through Chat Completions; use the Responses API"
547 );
548 }
549 Ok(())
550 }
551
552 fn validate_requested_api_surface(&self) -> Result<()> {
553 if self
554 .reasoning
555 .as_ref()
556 .is_some_and(|config| matches!(config.api_surface(), OpenAIApiSurface::ChatCompletions))
557 && requires_responses_api(&self.model)
558 {
559 anyhow::bail!(
560 "model={} is only available through the OpenAI Responses API",
561 self.model
562 );
563 }
564 Ok(())
565 }
566
567 fn validate_chat_reasoning_controls(config: Option<&OpenAIReasoningConfig>) -> Result<()> {
568 let Some(config) = config else {
569 return Ok(());
570 };
571 if config.mode().is_some() || config.context().is_some() || config.summary().is_some() {
572 anyhow::bail!(
573 "OpenAI reasoning mode, context, and summary controls require the Responses API"
574 );
575 }
576 Ok(())
577 }
578
579 fn validate_chat_response_format(
580 &self,
581 response_format: Option<&agent_sdk_foundation::llm::ResponseFormat>,
582 ) -> Result<()> {
583 if !is_official_openai_base_url(&self.base_url) {
584 return Ok(());
585 }
586 let Some(response_format) = response_format.filter(|format| format.strict) else {
587 return Ok(());
588 };
589 let mut schema = response_format.schema.clone();
590 if !normalize_strict_schema(&mut schema) {
591 anyhow::bail!(
592 "OpenAI strict structured output `{}` contains a free-form object schema",
593 response_format.name
594 );
595 }
596 Ok(())
597 }
598
599 fn resolve_chat_prompt_cache_options(
600 &self,
601 request: &ChatRequest,
602 config: Option<&OpenAIReasoningConfig>,
603 ) -> Result<ChatPromptCachePlan> {
604 let exact_options = ApiPromptCacheOptions::from_config(config);
605 if !is_gpt56_model(&self.model) && request.cache.is_some() {
606 return Ok(ChatPromptCachePlan {
607 options: exact_options,
608 explicit_breakpoints: 0,
609 });
610 }
611 let Some(cache) = request.cache.as_ref() else {
612 return Ok(ChatPromptCachePlan {
613 options: exact_options,
614 explicit_breakpoints: 0,
615 });
616 };
617
618 if let Some(ttl) = cache.ttl {
619 anyhow::bail!(
620 "OpenAI GPT-5.6 prompt caching supports only a 30m TTL; shared cache TTL {} cannot be represented for model={}",
621 ttl.as_wire_str(),
622 self.model
623 );
624 }
625
626 if !cache.enabled {
627 return Ok(ChatPromptCachePlan {
628 options: Some(ApiPromptCacheOptions::new(OpenAIPromptCacheMode::Explicit)),
629 explicit_breakpoints: 0,
630 });
631 }
632
633 if let Some(max_breakpoints) = cache.max_breakpoints {
634 return Ok(ChatPromptCachePlan {
635 options: Some(ApiPromptCacheOptions {
636 mode: Some(OpenAIPromptCacheMode::Explicit),
637 ttl: config.and_then(OpenAIReasoningConfig::prompt_cache_ttl),
638 }),
639 explicit_breakpoints: usize::from(max_breakpoints.min(4)),
640 });
641 }
642
643 Ok(ChatPromptCachePlan {
644 options: exact_options,
645 explicit_breakpoints: 0,
646 })
647 }
648
649 fn resolve_chat_tool_choice(
650 request_choice: Option<&ToolChoice>,
651 config: Option<&OpenAIReasoningConfig>,
652 tools: Option<&[agent_sdk_foundation::llm::Tool]>,
653 ) -> Result<Option<ApiToolChoice>> {
654 if let Some(choice) = request_choice {
655 if let ToolChoice::Tool(name) = choice
656 && !tools
657 .unwrap_or_default()
658 .iter()
659 .any(|tool| tool.name == *name)
660 {
661 anyhow::bail!("OpenAI tool_choice names unknown function `{name}`");
662 }
663 return Ok(Some(ApiToolChoice::from_tool_choice(choice)));
664 }
665
666 validate_tool_choice(config, tools)?;
667 Ok(config
668 .and_then(OpenAIReasoningConfig::tool_choice)
669 .map(ApiToolChoice::from_openai_tool_choice))
670 }
671
672 async fn send_chat_request(
673 &self,
674 api_request: &ApiChatRequest<'_>,
675 ) -> Result<(StatusCode, Vec<u8>, Option<std::time::Duration>)> {
676 let builder = self
677 .client
678 .post(format!("{}/chat/completions", self.base_url))
679 .header("Content-Type", "application/json");
680 let response = self
681 .apply_headers(builder)
682 .json(api_request)
683 .send()
684 .await
685 .map_err(|error| anyhow::anyhow!("request failed: {error}"))?;
686 let status = response.status();
687 let retry_after = (status == StatusCode::TOO_MANY_REQUESTS)
688 .then(|| crate::http::retry_after_from_headers(response.headers()))
689 .flatten();
690 let bytes = response
691 .bytes()
692 .await
693 .map_err(|error| anyhow::anyhow!("failed to read response body: {error}"))?
694 .to_vec();
695 Ok((status, bytes, retry_after))
696 }
697
698 fn responses_reroute(&self) -> OpenAIResponsesProvider {
703 let mut provider = OpenAIResponsesProvider::with_base_url(
704 self.api_key.clone(),
705 self.model.clone(),
706 self.base_url.clone(),
707 )
708 .with_client(self.client.clone())
709 .with_extra_headers(self.extra_headers.clone());
710 if let Some(thinking) = self.thinking.clone() {
711 provider = provider.with_thinking(thinking);
712 }
713 if let Some(reasoning) = self.reasoning.clone() {
714 provider = provider.with_reasoning(reasoning);
715 }
716 provider
717 }
718}
719
720#[async_trait]
721impl LlmProvider for OpenAIProvider {
722 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
723 if let Err(error) = self.validate_requested_api_surface() {
724 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
725 }
726 if should_use_responses_api(
729 &self.base_url,
730 &self.model,
731 &request,
732 self.reasoning.as_ref(),
733 ) {
734 return self.responses_reroute().chat(request).await;
735 }
736
737 let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
738 Ok(reasoning) => reasoning,
739 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
740 };
741 if let Err(error) = Self::validate_chat_reasoning_controls(reasoning_config.as_ref()) {
742 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
743 }
744 if let Err(error) = Self::validate_chat_opaque_reasoning(&request) {
745 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
746 }
747 if let Err(error) = self.validate_chat_attachments(&request) {
748 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
749 }
750 if let Err(error) = self.validate_chat_response_format(request.response_format.as_ref()) {
751 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
752 }
753 let prompt_cache =
754 match self.resolve_chat_prompt_cache_options(&request, reasoning_config.as_ref()) {
755 Ok(plan) => plan,
756 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
757 };
758 let tool_choice = match Self::resolve_chat_tool_choice(
759 request.tool_choice.as_ref(),
760 reasoning_config.as_ref(),
761 request.tools.as_deref(),
762 ) {
763 Ok(choice) => choice,
764 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
765 };
766 let official_openai = is_official_openai_base_url(&self.base_url);
767 let reasoning = build_chat_api_reasoning(
768 reasoning_config.as_ref(),
769 official_openai || self.reasoning.is_some(),
770 );
771 let verbosity = reasoning_config
772 .as_ref()
773 .and_then(OpenAIReasoningConfig::verbosity);
774 let store = chat_store(&self.base_url, reasoning_config.as_ref());
775 let parallel_tool_calls = reasoning_config
776 .as_ref()
777 .and_then(OpenAIReasoningConfig::parallel_tool_calls);
778 let safety_identifier = reasoning_config
779 .as_ref()
780 .and_then(OpenAIReasoningConfig::safety_identifier);
781 let prompt_cache_key = chat_prompt_cache_key(&self.base_url, request.session_id.as_deref());
782 let max_tokens = self.effective_max_tokens(&request);
783 let messages = build_api_messages_with_cache(&request, prompt_cache.explicit_breakpoints);
784 let tools: Option<Vec<ApiTool>> = request.tools.map(|ts| {
785 ts.into_iter()
786 .map(|tool| convert_tool(tool, official_openai))
787 .collect()
788 });
789 let response_format = request
790 .response_format
791 .as_ref()
792 .map(|format| ApiResponseFormat::from_response_format(format, official_openai));
793
794 let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
795 let api_request = ApiChatRequest {
796 model: &self.model,
797 messages: &messages,
798 max_completion_tokens: Some(max_tokens),
799 max_tokens: include_max_tokens_alias.then_some(max_tokens),
800 tools: tools.as_deref(),
801 tool_choice,
802 reasoning,
803 response_format,
804 verbosity,
805 prompt_cache_options: prompt_cache.options,
806 store,
807 parallel_tool_calls,
808 safety_identifier,
809 prompt_cache_key,
810 };
811
812 log::debug!(
813 "OpenAI LLM request model={} max_tokens={}",
814 self.model,
815 max_tokens
816 );
817
818 let (status, bytes, retry_after) = self.send_chat_request(&api_request).await?;
819
820 log::debug!(
821 "OpenAI LLM response status={} body_len={}",
822 status,
823 bytes.len()
824 );
825
826 decode_chat_response(status, &bytes, retry_after)
827 }
828
829 #[allow(clippy::too_many_lines)]
830 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
831 if let Err(error) = self.validate_requested_api_surface() {
832 return Box::pin(async_stream::stream! {
833 yield Ok(StreamDelta::Error {
834 message: error.to_string(),
835 kind: StreamErrorKind::InvalidRequest,
836 });
837 });
838 }
839 if should_use_responses_api(
842 &self.base_url,
843 &self.model,
844 &request,
845 self.reasoning.as_ref(),
846 ) {
847 let responses_provider = self.responses_reroute();
848 return Box::pin(async_stream::stream! {
849 let mut stream = std::pin::pin!(responses_provider.chat_stream(request));
850 while let Some(item) = futures::StreamExt::next(&mut stream).await {
851 yield item;
852 }
853 });
854 }
855
856 Box::pin(async_stream::stream! {
857 let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
858 Ok(reasoning) => reasoning,
859 Err(error) => {
860 yield Ok(StreamDelta::Error {
861 message: error.to_string(),
862 kind: StreamErrorKind::InvalidRequest,
863 });
864 return;
865 }
866 };
867 if let Err(error) = Self::validate_chat_reasoning_controls(reasoning_config.as_ref()) {
868 yield Ok(StreamDelta::Error {
869 message: error.to_string(),
870 kind: StreamErrorKind::InvalidRequest,
871 });
872 return;
873 }
874 if let Err(error) = Self::validate_chat_opaque_reasoning(&request) {
875 yield Ok(StreamDelta::Error {
876 message: error.to_string(),
877 kind: StreamErrorKind::InvalidRequest,
878 });
879 return;
880 }
881 if let Err(error) = self.validate_chat_attachments(&request) {
882 yield Ok(StreamDelta::Error {
883 message: error.to_string(),
884 kind: StreamErrorKind::InvalidRequest,
885 });
886 return;
887 }
888 if let Err(error) = self.validate_chat_response_format(request.response_format.as_ref()) {
889 yield Ok(StreamDelta::Error {
890 message: error.to_string(),
891 kind: StreamErrorKind::InvalidRequest,
892 });
893 return;
894 }
895 let prompt_cache = match self
896 .resolve_chat_prompt_cache_options(&request, reasoning_config.as_ref())
897 {
898 Ok(plan) => plan,
899 Err(error) => {
900 yield Ok(StreamDelta::Error {
901 message: error.to_string(),
902 kind: StreamErrorKind::InvalidRequest,
903 });
904 return;
905 }
906 };
907 let tool_choice = match Self::resolve_chat_tool_choice(
908 request.tool_choice.as_ref(),
909 reasoning_config.as_ref(),
910 request.tools.as_deref(),
911 ) {
912 Ok(choice) => choice,
913 Err(error) => {
914 yield Ok(StreamDelta::Error {
915 message: error.to_string(),
916 kind: StreamErrorKind::InvalidRequest,
917 });
918 return;
919 }
920 };
921 let official_openai = is_official_openai_base_url(&self.base_url);
922 let reasoning = build_chat_api_reasoning(
923 reasoning_config.as_ref(),
924 official_openai || self.reasoning.is_some(),
925 );
926 let verbosity = reasoning_config
927 .as_ref()
928 .and_then(OpenAIReasoningConfig::verbosity);
929 let store = chat_store(&self.base_url, reasoning_config.as_ref());
930 let parallel_tool_calls = reasoning_config
931 .as_ref()
932 .and_then(OpenAIReasoningConfig::parallel_tool_calls);
933 let safety_identifier = reasoning_config
934 .as_ref()
935 .and_then(OpenAIReasoningConfig::safety_identifier);
936 let prompt_cache_key =
937 chat_prompt_cache_key(&self.base_url, request.session_id.as_deref());
938 let max_tokens = self.effective_max_tokens(&request);
939 let messages =
940 build_api_messages_with_cache(&request, prompt_cache.explicit_breakpoints);
941 let tools: Option<Vec<ApiTool>> = request
942 .tools
943 .map(|ts| {
944 ts.into_iter()
945 .map(|tool| convert_tool(tool, official_openai))
946 .collect()
947 });
948 let response_format = request
949 .response_format
950 .as_ref()
951 .map(|format| ApiResponseFormat::from_response_format(format, official_openai));
952
953 let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
954 let include_stream_usage = use_stream_usage_options(&self.base_url);
955 let include_openrouter_usage = use_openrouter_usage_options(&self.base_url);
956 let api_request = ApiChatRequestStreaming {
957 model: &self.model,
958 messages: &messages,
959 max_completion_tokens: Some(max_tokens),
960 max_tokens: include_max_tokens_alias.then_some(max_tokens),
961 tools: tools.as_deref(),
962 tool_choice,
963 reasoning,
964 response_format,
965 verbosity,
966 prompt_cache_options: prompt_cache.options,
967 store,
968 parallel_tool_calls,
969 safety_identifier,
970 prompt_cache_key,
971 stream_options: include_stream_usage.then_some(ApiStreamOptions {
972 include_usage: true,
973 }),
974 usage: include_openrouter_usage
975 .then_some(ApiOpenRouterUsageOptions { include: true }),
976 stream: true,
977 };
978
979 log::debug!("OpenAI streaming LLM request model={} max_tokens={}", self.model, max_tokens);
980
981 let stream_builder = self.client
982 .post(format!("{}/chat/completions", self.base_url))
983 .header("Content-Type", "application/json");
984 let Ok(response) = self
985 .apply_headers(stream_builder)
986 .json(&api_request)
987 .send()
988 .await
989 else {
990 yield Err(anyhow::anyhow!("request failed"));
991 return;
992 };
993
994 let status = response.status();
995
996 if !status.is_success() {
997 let body = response.text().await.unwrap_or_default();
998 let (kind, level) = if status == StatusCode::TOO_MANY_REQUESTS {
999 (StreamErrorKind::RateLimited, "rate_limit")
1000 } else if status.is_server_error() {
1001 (StreamErrorKind::ServerError, "server_error")
1002 } else {
1003 (StreamErrorKind::InvalidRequest, "client_error")
1004 };
1005 log::warn!("OpenAI error status={status} body={body} kind={level}");
1006 yield Ok(StreamDelta::Error { message: body, kind });
1007 return;
1008 }
1009
1010 let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
1012 let mut usage: Option<Usage> = None;
1013 let mut stop_reason: Option<StopReason> = None;
1019 let mut sse = SseLineBuffer::new();
1020 let mut stream = response.bytes_stream();
1021
1022 while let Some(chunk_result) = stream.next().await {
1023 let chunk = match chunk_result {
1024 Ok(chunk) => chunk,
1025 Err(error) => {
1026 yield Err(anyhow::anyhow!("stream error: {error}"));
1027 return;
1028 }
1029 };
1030 sse.extend(&chunk);
1031
1032 while let Some(line) = sse.next_line() {
1033 let line = line.trim();
1034 if line.is_empty() { continue; }
1035 let Some(data) = line.strip_prefix("data: ") else { continue; };
1036
1037 let outcome = step_completion_stream(
1038 data,
1039 &mut tool_calls,
1040 &mut usage,
1041 &mut stop_reason,
1042 );
1043 for delta in outcome.immediate { yield Ok(delta); }
1044 if let Some(terminal) = outcome.terminal {
1045 for delta in terminal { yield Ok(delta); }
1046 return;
1047 }
1048 }
1049 }
1050
1051 yield Err(anyhow::anyhow!("OpenAI stream ended before [DONE] sentinel"));
1056 })
1057 }
1058
1059 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
1060 let builder = self
1061 .client
1062 .get(format!("{}/models", self.base_url))
1063 .header("Content-Type", "application/json");
1064 let builder = self.apply_headers(builder);
1065 let body = crate::impls::model_listing::fetch_model_list_body(builder, "OpenAI").await?;
1066 parse_models_list(&body)
1067 }
1068
1069 fn model(&self) -> &str {
1070 &self.model
1071 }
1072
1073 fn provider(&self) -> &'static str {
1074 "openai"
1075 }
1076
1077 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1078 self.thinking.as_ref()
1079 }
1080}
1081
1082fn parse_models_list(body: &str) -> Result<Vec<crate::provider::ModelInfo>> {
1088 #[derive(Deserialize)]
1089 struct ListResponse {
1090 #[serde(default)]
1091 data: Vec<ModelRow>,
1092 }
1093 #[derive(Deserialize)]
1094 struct ModelRow {
1095 id: String,
1096 }
1097 let parsed: ListResponse = serde_json::from_str(body)
1098 .map_err(|e| anyhow::anyhow!("failed to parse OpenAI models list: {e}"))?;
1099 Ok(parsed
1100 .data
1101 .into_iter()
1102 .map(|row| crate::provider::ModelInfo {
1103 id: row.id,
1104 display_name: None,
1105 context_window: None,
1106 max_output_tokens: None,
1107 })
1108 .collect())
1109}
1110
1111fn apply_tool_call_update(
1113 tool_calls: &mut std::collections::HashMap<usize, ToolCallAccumulator>,
1114 index: usize,
1115 id: Option<String>,
1116 name: Option<String>,
1117 arguments: Option<String>,
1118) {
1119 let entry = tool_calls
1120 .entry(index)
1121 .or_insert_with(|| ToolCallAccumulator {
1122 id: String::new(),
1123 name: String::new(),
1124 arguments: String::new(),
1125 });
1126 if let Some(id) = id {
1127 entry.id = id;
1128 }
1129 if let Some(name) = name {
1130 entry.name = name;
1131 }
1132 if let Some(args) = arguments {
1133 entry.arguments.push_str(&args);
1134 }
1135}
1136
1137struct SseLineOutcome {
1140 immediate: Vec<StreamDelta>,
1142 terminal: Option<Vec<StreamDelta>>,
1145}
1146
1147fn step_completion_stream(
1155 data: &str,
1156 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
1157 usage: &mut Option<Usage>,
1158 stop_reason: &mut Option<StopReason>,
1159) -> SseLineOutcome {
1160 let mut immediate = Vec::new();
1161 for result in process_sse_data(data) {
1162 match result {
1163 SseProcessResult::TextDelta(c) => {
1164 immediate.push(StreamDelta::TextDelta {
1165 delta: c,
1166 block_index: 0,
1167 });
1168 }
1169 SseProcessResult::RefusalDelta(c) => {
1170 immediate.push(StreamDelta::TextDelta {
1171 delta: c,
1172 block_index: 0,
1173 });
1174 *stop_reason = Some(StopReason::Refusal);
1175 }
1176 SseProcessResult::ThinkingDelta(c) => {
1177 immediate.push(StreamDelta::ThinkingDelta {
1178 delta: c,
1179 block_index: 0,
1180 });
1181 }
1182 SseProcessResult::ToolCallUpdate {
1183 index,
1184 id,
1185 name,
1186 arguments,
1187 } => apply_tool_call_update(tool_calls, index, id, name, arguments),
1188 SseProcessResult::Usage(u) => *usage = Some(u),
1189 SseProcessResult::Done(sr) => {
1190 if !matches!(stop_reason, Some(StopReason::Refusal)) {
1191 *stop_reason = Some(sr);
1192 }
1193 }
1194 SseProcessResult::Malformed(message) => {
1195 return SseLineOutcome {
1196 immediate,
1197 terminal: Some(vec![StreamDelta::Error {
1198 message,
1199 kind: StreamErrorKind::ServerError,
1200 }]),
1201 };
1202 }
1203 SseProcessResult::Sentinel => {
1204 let sr = stop_reason.unwrap_or_else(|| fallback_stream_stop_reason(tool_calls));
1205 let terminal = build_stream_end_deltas(tool_calls, usage.take(), sr);
1206 return SseLineOutcome {
1207 immediate,
1208 terminal: Some(terminal),
1209 };
1210 }
1211 }
1212 }
1213 SseLineOutcome {
1214 immediate,
1215 terminal: None,
1216 }
1217}
1218
1219fn build_stream_end_deltas(
1221 tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
1222 usage: Option<Usage>,
1223 stop_reason: StopReason,
1224) -> Vec<StreamDelta> {
1225 let mut deltas = Vec::new();
1226
1227 if matches!(stop_reason, StopReason::ToolUse) {
1228 for (idx, tool) in tool_calls {
1232 let block_index = idx.saturating_add(1);
1233 deltas.push(StreamDelta::ToolUseStart {
1234 id: tool.id.clone(),
1235 name: tool.name.clone(),
1236 block_index,
1237 thought_signature: None,
1238 });
1239 deltas.push(StreamDelta::ToolInputDelta {
1240 id: tool.id.clone(),
1241 delta: tool.arguments.clone(),
1242 block_index,
1243 });
1244 }
1245 }
1246
1247 if let Some(u) = usage {
1249 deltas.push(StreamDelta::Usage(u));
1250 }
1251
1252 deltas.push(StreamDelta::Done {
1254 stop_reason: Some(stop_reason),
1255 });
1256
1257 deltas
1258}
1259
1260enum SseProcessResult {
1262 TextDelta(String),
1264 RefusalDelta(String),
1266 ThinkingDelta(String),
1270 ToolCallUpdate {
1272 index: usize,
1273 id: Option<String>,
1274 name: Option<String>,
1275 arguments: Option<String>,
1276 },
1277 Usage(Usage),
1279 Done(StopReason),
1281 Malformed(String),
1283 Sentinel,
1285}
1286
1287fn process_sse_data(data: &str) -> Vec<SseProcessResult> {
1289 if data == "[DONE]" {
1290 return vec![SseProcessResult::Sentinel];
1291 }
1292
1293 let chunk = match serde_json::from_str::<SseChunk>(data) {
1294 Ok(chunk) => chunk,
1295 Err(error) => {
1296 return vec![SseProcessResult::Malformed(format!(
1297 "invalid OpenAI Chat Completions stream event: {error}"
1298 ))];
1299 }
1300 };
1301
1302 let mut results = Vec::new();
1303
1304 if let Some(u) = chunk.usage {
1306 results.push(SseProcessResult::Usage(Usage {
1307 input_tokens: u.prompt_tokens,
1308 output_tokens: u.completion_tokens,
1309 cached_input_tokens: u
1310 .prompt_tokens_details
1311 .as_ref()
1312 .map_or(0, |details| details.cached_tokens),
1313 cache_creation_input_tokens: u
1314 .prompt_tokens_details
1315 .as_ref()
1316 .map_or(0, |details| details.cache_write_tokens),
1317 }));
1318 }
1319
1320 if let Some(choice) = chunk.choices.into_iter().next() {
1322 let content = choice.delta.content.filter(|content| !content.is_empty());
1323 let refusal = choice.delta.refusal.filter(|refusal| !refusal.is_empty());
1324 let has_visible_output = content.is_some() || refusal.is_some();
1325
1326 if let Some(content) = content {
1327 results.push(SseProcessResult::TextDelta(content));
1328 }
1329 if let Some(refusal) = refusal {
1330 results.push(SseProcessResult::RefusalDelta(refusal));
1331 }
1332 if !has_visible_output
1333 && let Some(reasoning) = choice
1334 .delta
1335 .reasoning_content
1336 .as_deref()
1337 .or(choice.delta.reasoning.as_deref())
1338 .filter(|reasoning| !reasoning.is_empty())
1339 {
1340 results.push(SseProcessResult::ThinkingDelta(reasoning.to_owned()));
1341 }
1342
1343 if let Some(tc_deltas) = choice.delta.tool_calls {
1345 for tc in tc_deltas {
1346 results.push(SseProcessResult::ToolCallUpdate {
1347 index: tc.index,
1348 id: tc.id,
1349 name: tc.function.as_ref().and_then(|f| f.name.clone()),
1350 arguments: tc.function.as_ref().and_then(|f| f.arguments.clone()),
1351 });
1352 }
1353 }
1354
1355 if let Some(finish_reason) = choice.finish_reason {
1357 results.push(SseProcessResult::Done(map_finish_reason(&finish_reason)));
1358 }
1359 }
1360
1361 results
1362}
1363
1364fn use_max_tokens_alias(base_url: &str) -> bool {
1365 base_url.contains("moonshot.ai")
1366 || base_url.contains("api.z.ai")
1367 || base_url.contains("minimax.io")
1368}
1369
1370const fn use_stream_usage_options(_base_url: &str) -> bool {
1375 true
1376}
1377
1378fn use_openrouter_usage_options(base_url: &str) -> bool {
1381 base_url.contains("openrouter.ai")
1382}
1383
1384fn fallback_stream_stop_reason(
1387 tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
1388) -> StopReason {
1389 if tool_calls.is_empty() {
1390 StopReason::EndTurn
1391 } else {
1392 StopReason::ToolUse
1393 }
1394}
1395
1396fn decode_chat_response(
1399 status: StatusCode,
1400 bytes: &[u8],
1401 retry_after: Option<std::time::Duration>,
1402) -> Result<ChatOutcome> {
1403 if status == StatusCode::TOO_MANY_REQUESTS {
1404 return Ok(ChatOutcome::RateLimited(retry_after));
1405 }
1406
1407 if status.is_server_error() {
1408 let body = String::from_utf8_lossy(bytes);
1409 log::error!("OpenAI server error status={status} body={body}");
1410 return Ok(ChatOutcome::ServerError(body.into_owned()));
1411 }
1412
1413 if status.is_client_error() {
1414 let body = String::from_utf8_lossy(bytes);
1415 log::warn!("OpenAI client error status={status} body={body}");
1416 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
1417 }
1418
1419 let api_response: ApiChatResponse = serde_json::from_slice(bytes)
1420 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
1421
1422 let choice = api_response
1423 .choices
1424 .into_iter()
1425 .next()
1426 .ok_or_else(|| anyhow::anyhow!("no choices in response"))?;
1427
1428 let stop_reason = if refusal_text(&choice.message).is_some() {
1429 Some(StopReason::Refusal)
1430 } else {
1431 choice.finish_reason.as_deref().map(map_finish_reason)
1432 };
1433 let mut content = build_content_blocks(&choice.message);
1434 if !matches!(stop_reason, Some(StopReason::ToolUse)) {
1435 content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
1436 }
1437
1438 Ok(ChatOutcome::Success(ChatResponse {
1439 id: api_response.id,
1440 content,
1441 model: api_response.model,
1442 stop_reason,
1443 usage: Usage {
1444 input_tokens: api_response.usage.prompt_tokens,
1445 output_tokens: api_response.usage.completion_tokens,
1446 cached_input_tokens: api_response
1447 .usage
1448 .prompt_tokens_details
1449 .as_ref()
1450 .map_or(0, |details| details.cached_tokens),
1451 cache_creation_input_tokens: api_response
1452 .usage
1453 .prompt_tokens_details
1454 .as_ref()
1455 .map_or(0, |details| details.cache_write_tokens),
1456 },
1457 }))
1458}
1459
1460fn map_finish_reason(finish_reason: &str) -> StopReason {
1461 match finish_reason {
1462 "stop" => StopReason::EndTurn,
1463 "tool_calls" => StopReason::ToolUse,
1464 "length" => StopReason::MaxTokens,
1465 "content_filter" | "refusal" | "sensitive" => StopReason::Refusal,
1466 "network_error" => StopReason::Unknown,
1467 unknown => {
1468 log::debug!("Unknown finish_reason from OpenAI-compatible API: {unknown}");
1469 StopReason::Unknown
1470 }
1471 }
1472}
1473
1474fn build_chat_api_reasoning(
1475 config: Option<&OpenAIReasoningConfig>,
1476 first_party_wire: bool,
1477) -> Option<ApiChatReasoning> {
1478 config
1479 .and_then(OpenAIReasoningConfig::effort)
1480 .map(|effort| {
1481 if first_party_wire {
1482 ApiChatReasoning {
1483 reasoning_effort: Some(effort),
1484 reasoning: None,
1485 }
1486 } else {
1487 ApiChatReasoning {
1488 reasoning_effort: None,
1489 reasoning: Some(ApiCompatibleReasoning { effort }),
1490 }
1491 }
1492 })
1493}
1494
1495const fn api_role(role: agent_sdk_foundation::llm::Role) -> ApiRole {
1496 match role {
1497 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1498 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1499 }
1500}
1501
1502fn append_block_messages(
1509 messages: &mut Vec<ApiMessage>,
1510 role: agent_sdk_foundation::llm::Role,
1511 blocks: &[ContentBlock],
1512) {
1513 let mut text_parts = Vec::new();
1514 let mut thinking_parts = Vec::new();
1515 let mut tool_calls = Vec::new();
1516
1517 for block in blocks {
1518 match block {
1519 ContentBlock::Text { text } => text_parts.push(text.clone()),
1520 ContentBlock::Thinking { thinking, .. } => {
1521 thinking_parts.push(thinking.clone());
1526 }
1527 ContentBlock::RedactedThinking { .. }
1528 | ContentBlock::Image { .. }
1529 | ContentBlock::Document { .. } => {
1530 }
1532 ContentBlock::ToolUse {
1533 id, name, input, ..
1534 } => {
1535 tool_calls.push(ApiToolCall {
1536 id: id.clone(),
1537 r#type: "function".to_owned(),
1538 function: ApiFunctionCall {
1539 name: name.clone(),
1540 arguments: serde_json::to_string(input).unwrap_or_else(|_| "{}".to_owned()),
1541 },
1542 });
1543 }
1544 ContentBlock::ToolResult {
1545 tool_use_id,
1546 content,
1547 ..
1548 } => {
1549 messages.push(ApiMessage {
1551 role: ApiRole::Tool,
1552 content: Some(content.clone()),
1553 reasoning_content: None,
1554 tool_calls: None,
1555 tool_call_id: Some(tool_use_id.clone()),
1556 prompt_cache_breakpoint: false,
1557 });
1558 }
1559 _ => log::warn!("Skipping unrecognized OpenAI content block"),
1562 }
1563 }
1564
1565 let role = api_role(role);
1566
1567 let reasoning_content =
1575 if role == ApiRole::Assistant && !thinking_parts.is_empty() && !tool_calls.is_empty() {
1576 Some(thinking_parts.join("\n"))
1577 } else {
1578 None
1579 };
1580
1581 let has_payload =
1585 !text_parts.is_empty() || !tool_calls.is_empty() || reasoning_content.is_some();
1586 if has_payload && (role == ApiRole::Assistant || !text_parts.is_empty()) {
1587 messages.push(ApiMessage {
1588 role,
1589 content: if text_parts.is_empty() {
1590 None
1591 } else {
1592 Some(text_parts.join("\n"))
1593 },
1594 reasoning_content,
1595 tool_calls: if tool_calls.is_empty() {
1596 None
1597 } else {
1598 Some(tool_calls)
1599 },
1600 tool_call_id: None,
1601 prompt_cache_breakpoint: false,
1602 });
1603 }
1604}
1605
1606#[cfg(test)]
1607fn build_api_messages(request: &ChatRequest) -> Vec<ApiMessage> {
1608 build_api_messages_with_cache(request, 0)
1609}
1610
1611fn build_api_messages_with_cache(
1612 request: &ChatRequest,
1613 explicit_cache_breakpoints: usize,
1614) -> Vec<ApiMessage> {
1615 let mut messages = Vec::new();
1616
1617 if !request.system.is_empty() {
1619 messages.push(ApiMessage {
1620 role: ApiRole::System,
1621 content: Some(request.system.clone()),
1622 reasoning_content: None,
1623 tool_calls: None,
1624 tool_call_id: None,
1625 prompt_cache_breakpoint: false,
1626 });
1627 }
1628
1629 for msg in &request.messages {
1631 match &msg.content {
1632 Content::Text(text) => {
1633 messages.push(ApiMessage {
1634 role: api_role(msg.role),
1635 content: Some(text.clone()),
1636 reasoning_content: None,
1637 tool_calls: None,
1638 tool_call_id: None,
1639 prompt_cache_breakpoint: false,
1640 });
1641 }
1642 Content::Blocks(blocks) => append_block_messages(&mut messages, msg.role, blocks),
1643 }
1644 }
1645
1646 apply_chat_cache_breakpoints(&mut messages, explicit_cache_breakpoints);
1647 messages
1648}
1649
1650fn apply_chat_cache_breakpoints(messages: &mut [ApiMessage], max_breakpoints: usize) {
1651 if max_breakpoints == 0 {
1652 return;
1653 }
1654
1655 let max_breakpoints = max_breakpoints.min(4);
1656 let message_indices: Vec<usize> = messages
1657 .iter()
1658 .enumerate()
1659 .filter_map(|(index, message)| message.content.as_ref().map(|_| index))
1660 .collect();
1661
1662 let mut selected = Vec::with_capacity(max_breakpoints);
1663 if let Some(system_index) = message_indices
1667 .iter()
1668 .copied()
1669 .find(|index| messages[*index].role == ApiRole::System)
1670 {
1671 selected.push(system_index);
1672 }
1673
1674 let remaining = max_breakpoints.saturating_sub(selected.len());
1675 let tail_candidates: Vec<usize> = message_indices
1676 .into_iter()
1677 .filter(|index| !selected.contains(index))
1678 .collect();
1679 let keep_from = tail_candidates.len().saturating_sub(remaining);
1680 selected.extend_from_slice(&tail_candidates[keep_from..]);
1681
1682 for index in selected {
1683 messages[index].prompt_cache_breakpoint = true;
1684 }
1685}
1686
1687fn convert_tool(t: agent_sdk_foundation::llm::Tool, normalize_for_openai_strict: bool) -> ApiTool {
1688 let mut parameters = t.input_schema;
1689 let strict =
1690 (normalize_for_openai_strict && normalize_strict_schema(&mut parameters)).then_some(true);
1691 ApiTool {
1692 r#type: "function".to_owned(),
1693 function: ApiFunction {
1694 name: t.name,
1695 description: t.description,
1696 parameters,
1697 strict,
1698 },
1699 }
1700}
1701
1702fn reasoning_text(message: &ApiResponseMessage) -> Option<&str> {
1707 message
1708 .reasoning_content
1709 .as_deref()
1710 .or(message.reasoning.as_deref())
1711 .filter(|r| !r.is_empty())
1712}
1713
1714fn refusal_text(message: &ApiResponseMessage) -> Option<&str> {
1715 message
1716 .refusal
1717 .as_deref()
1718 .filter(|refusal| !refusal.is_empty())
1719}
1720
1721fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
1722 let mut blocks = Vec::new();
1723
1724 let content = message
1725 .content
1726 .as_ref()
1727 .filter(|content| !content.is_empty());
1728 let refusal = refusal_text(message);
1729 let has_visible_output = content.is_some() || refusal.is_some();
1730 if let Some(content) = content {
1731 blocks.push(ContentBlock::Text {
1732 text: content.clone(),
1733 });
1734 }
1735 if let Some(refusal) = refusal {
1736 blocks.push(ContentBlock::Text {
1737 text: refusal.to_owned(),
1738 });
1739 }
1740 if !has_visible_output && let Some(reasoning) = reasoning_text(message) {
1741 blocks.push(ContentBlock::Thinking {
1748 thinking: reasoning.to_owned(),
1749 signature: None,
1750 });
1751 }
1752
1753 if let Some(tool_calls) = &message.tool_calls {
1755 for tc in tool_calls {
1756 let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
1757 .unwrap_or_else(|_| serde_json::json!({}));
1758 blocks.push(ContentBlock::ToolUse {
1759 id: tc.id.clone(),
1760 name: tc.function.name.clone(),
1761 input,
1762 thought_signature: None,
1763 });
1764 }
1765 }
1766
1767 blocks
1768}
1769
1770#[derive(Serialize)]
1775struct ApiChatRequest<'a> {
1776 model: &'a str,
1777 messages: &'a [ApiMessage],
1778 #[serde(skip_serializing_if = "Option::is_none")]
1779 max_completion_tokens: Option<u32>,
1780 #[serde(skip_serializing_if = "Option::is_none")]
1781 max_tokens: Option<u32>,
1782 #[serde(skip_serializing_if = "Option::is_none")]
1783 tools: Option<&'a [ApiTool]>,
1784 #[serde(skip_serializing_if = "Option::is_none")]
1785 tool_choice: Option<ApiToolChoice>,
1786 #[serde(flatten)]
1787 reasoning: Option<ApiChatReasoning>,
1788 #[serde(skip_serializing_if = "Option::is_none")]
1789 response_format: Option<ApiResponseFormat>,
1790 #[serde(skip_serializing_if = "Option::is_none")]
1791 verbosity: Option<OpenAITextVerbosity>,
1792 #[serde(skip_serializing_if = "Option::is_none")]
1793 prompt_cache_options: Option<ApiPromptCacheOptions>,
1794 #[serde(skip_serializing_if = "Option::is_none")]
1795 store: Option<bool>,
1796 #[serde(skip_serializing_if = "Option::is_none")]
1797 parallel_tool_calls: Option<bool>,
1798 #[serde(skip_serializing_if = "Option::is_none")]
1799 safety_identifier: Option<&'a str>,
1800 #[serde(skip_serializing_if = "Option::is_none")]
1801 prompt_cache_key: Option<&'a str>,
1802}
1803
1804#[derive(Serialize)]
1805struct ApiChatRequestStreaming<'a> {
1806 model: &'a str,
1807 messages: &'a [ApiMessage],
1808 #[serde(skip_serializing_if = "Option::is_none")]
1809 max_completion_tokens: Option<u32>,
1810 #[serde(skip_serializing_if = "Option::is_none")]
1811 max_tokens: Option<u32>,
1812 #[serde(skip_serializing_if = "Option::is_none")]
1813 tools: Option<&'a [ApiTool]>,
1814 #[serde(skip_serializing_if = "Option::is_none")]
1815 tool_choice: Option<ApiToolChoice>,
1816 #[serde(flatten)]
1817 reasoning: Option<ApiChatReasoning>,
1818 #[serde(skip_serializing_if = "Option::is_none")]
1819 response_format: Option<ApiResponseFormat>,
1820 #[serde(skip_serializing_if = "Option::is_none")]
1821 verbosity: Option<OpenAITextVerbosity>,
1822 #[serde(skip_serializing_if = "Option::is_none")]
1823 prompt_cache_options: Option<ApiPromptCacheOptions>,
1824 #[serde(skip_serializing_if = "Option::is_none")]
1825 store: Option<bool>,
1826 #[serde(skip_serializing_if = "Option::is_none")]
1827 parallel_tool_calls: Option<bool>,
1828 #[serde(skip_serializing_if = "Option::is_none")]
1829 safety_identifier: Option<&'a str>,
1830 #[serde(skip_serializing_if = "Option::is_none")]
1831 prompt_cache_key: Option<&'a str>,
1832 #[serde(skip_serializing_if = "Option::is_none")]
1833 stream_options: Option<ApiStreamOptions>,
1834 #[serde(skip_serializing_if = "Option::is_none")]
1835 usage: Option<ApiOpenRouterUsageOptions>,
1836 stream: bool,
1837}
1838
1839#[derive(Serialize)]
1844#[serde(untagged)]
1845enum ApiToolChoice {
1846 Mode(&'static str),
1847 Named {
1848 #[serde(rename = "type")]
1849 choice_type: &'static str,
1850 function: ApiToolChoiceFunction,
1851 },
1852 AllowedTools {
1853 #[serde(rename = "type")]
1854 choice_type: &'static str,
1855 allowed_tools: ApiChatAllowedTools,
1856 },
1857}
1858
1859#[derive(Serialize)]
1860struct ApiToolChoiceFunction {
1861 name: String,
1862}
1863
1864#[derive(Serialize)]
1865struct ApiChatAllowedTools {
1866 mode: OpenAIAllowedToolsMode,
1867 tools: Vec<ApiChatAllowedTool>,
1868}
1869
1870#[derive(Serialize)]
1871struct ApiChatAllowedTool {
1872 #[serde(rename = "type")]
1873 tool_type: &'static str,
1874 function: ApiToolChoiceFunction,
1875}
1876
1877impl ApiToolChoice {
1878 fn from_tool_choice(tc: &ToolChoice) -> Self {
1879 match tc {
1880 ToolChoice::Auto => Self::Mode("auto"),
1881 ToolChoice::Tool(name) => Self::Named {
1882 choice_type: "function",
1883 function: ApiToolChoiceFunction { name: name.clone() },
1884 },
1885 }
1886 }
1887
1888 fn from_openai_tool_choice(choice: &OpenAIToolChoice) -> Self {
1889 match choice {
1890 OpenAIToolChoice::None => Self::Mode("none"),
1891 OpenAIToolChoice::Auto => Self::Mode("auto"),
1892 OpenAIToolChoice::Required => Self::Mode("required"),
1893 OpenAIToolChoice::Function(name) => Self::Named {
1894 choice_type: "function",
1895 function: ApiToolChoiceFunction { name: name.clone() },
1896 },
1897 OpenAIToolChoice::AllowedTools { mode, tools } => Self::AllowedTools {
1898 choice_type: "allowed_tools",
1899 allowed_tools: ApiChatAllowedTools {
1900 mode: *mode,
1901 tools: tools
1902 .iter()
1903 .map(|name| ApiChatAllowedTool {
1904 tool_type: "function",
1905 function: ApiToolChoiceFunction { name: name.clone() },
1906 })
1907 .collect(),
1908 },
1909 },
1910 }
1911 }
1912}
1913
1914#[derive(Serialize)]
1918struct ApiResponseFormat {
1919 #[serde(rename = "type")]
1920 format_type: &'static str,
1921 json_schema: ApiJsonSchema,
1922}
1923
1924#[derive(Serialize)]
1925struct ApiJsonSchema {
1926 name: String,
1927 schema: serde_json::Value,
1928 strict: bool,
1929}
1930
1931impl ApiResponseFormat {
1932 fn from_response_format(
1933 rf: &agent_sdk_foundation::llm::ResponseFormat,
1934 normalize_for_openai_strict: bool,
1935 ) -> Self {
1936 let mut schema = rf.schema.clone();
1937 if rf.strict && normalize_for_openai_strict {
1938 let _ = normalize_strict_schema(&mut schema);
1939 }
1940 Self {
1941 format_type: "json_schema",
1942 json_schema: ApiJsonSchema {
1943 name: rf.name.clone(),
1944 schema,
1945 strict: rf.strict,
1946 },
1947 }
1948 }
1949}
1950
1951#[derive(Clone, Copy, Serialize)]
1952struct ApiStreamOptions {
1953 include_usage: bool,
1954}
1955
1956#[derive(Clone, Copy, Serialize)]
1959struct ApiOpenRouterUsageOptions {
1960 include: bool,
1961}
1962
1963#[derive(Serialize)]
1964struct ApiChatReasoning {
1965 #[serde(skip_serializing_if = "Option::is_none")]
1966 reasoning_effort: Option<OpenAIReasoningEffort>,
1967 #[serde(skip_serializing_if = "Option::is_none")]
1968 reasoning: Option<ApiCompatibleReasoning>,
1969}
1970
1971#[derive(Serialize)]
1972struct ApiCompatibleReasoning {
1973 effort: OpenAIReasoningEffort,
1974}
1975
1976#[derive(Clone, Copy, Serialize)]
1977struct ApiPromptCacheOptions {
1978 #[serde(skip_serializing_if = "Option::is_none")]
1979 mode: Option<OpenAIPromptCacheMode>,
1980 #[serde(skip_serializing_if = "Option::is_none")]
1981 ttl: Option<OpenAIPromptCacheTtl>,
1982}
1983
1984#[derive(Clone, Copy)]
1985struct ChatPromptCachePlan {
1986 options: Option<ApiPromptCacheOptions>,
1987 explicit_breakpoints: usize,
1988}
1989
1990impl ApiPromptCacheOptions {
1991 const fn new(mode: OpenAIPromptCacheMode) -> Self {
1992 Self {
1993 mode: Some(mode),
1994 ttl: None,
1995 }
1996 }
1997
1998 fn from_config(config: Option<&OpenAIReasoningConfig>) -> Option<Self> {
1999 let config = config?;
2000 let options = Self {
2001 mode: config.prompt_cache_mode(),
2002 ttl: config.prompt_cache_ttl(),
2003 };
2004 (options.mode.is_some() || options.ttl.is_some()).then_some(options)
2005 }
2006}
2007
2008struct ApiMessage {
2009 role: ApiRole,
2010 content: Option<String>,
2011 reasoning_content: Option<String>,
2018 tool_calls: Option<Vec<ApiToolCall>>,
2019 tool_call_id: Option<String>,
2020 prompt_cache_breakpoint: bool,
2021}
2022
2023impl Serialize for ApiMessage {
2024 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2025 where
2026 S: serde::Serializer,
2027 {
2028 let mut message = serializer.serialize_struct("ApiMessage", 5)?;
2029 message.serialize_field("role", &self.role)?;
2030 if let Some(content) = self.content.as_deref() {
2031 if self.prompt_cache_breakpoint {
2032 message.serialize_field(
2033 "content",
2034 &[ApiChatTextPart {
2035 part_type: "text",
2036 text: content,
2037 prompt_cache_breakpoint: ApiChatPromptCacheBreakpoint {
2038 mode: OpenAIPromptCacheMode::Explicit,
2039 },
2040 }],
2041 )?;
2042 } else {
2043 message.serialize_field("content", content)?;
2044 }
2045 }
2046 if let Some(reasoning_content) = self.reasoning_content.as_deref() {
2047 message.serialize_field("reasoning_content", reasoning_content)?;
2048 }
2049 if let Some(tool_calls) = self.tool_calls.as_ref() {
2050 message.serialize_field("tool_calls", tool_calls)?;
2051 }
2052 if let Some(tool_call_id) = self.tool_call_id.as_deref() {
2053 message.serialize_field("tool_call_id", tool_call_id)?;
2054 }
2055 message.end()
2056 }
2057}
2058
2059#[derive(Serialize)]
2060struct ApiChatTextPart<'a> {
2061 #[serde(rename = "type")]
2062 part_type: &'static str,
2063 text: &'a str,
2064 prompt_cache_breakpoint: ApiChatPromptCacheBreakpoint,
2065}
2066
2067#[derive(Clone, Copy, Serialize)]
2068struct ApiChatPromptCacheBreakpoint {
2069 mode: OpenAIPromptCacheMode,
2070}
2071
2072#[derive(Debug, Serialize, PartialEq, Eq)]
2073#[serde(rename_all = "lowercase")]
2074enum ApiRole {
2075 System,
2076 User,
2077 Assistant,
2078 Tool,
2079}
2080
2081#[derive(Serialize)]
2082struct ApiToolCall {
2083 id: String,
2084 r#type: String,
2085 function: ApiFunctionCall,
2086}
2087
2088#[derive(Serialize)]
2089struct ApiFunctionCall {
2090 name: String,
2091 arguments: String,
2092}
2093
2094#[derive(Serialize)]
2095struct ApiTool {
2096 r#type: String,
2097 function: ApiFunction,
2098}
2099
2100#[derive(Serialize)]
2101struct ApiFunction {
2102 name: String,
2103 description: String,
2104 parameters: serde_json::Value,
2105 #[serde(skip_serializing_if = "Option::is_none")]
2106 strict: Option<bool>,
2107}
2108
2109#[derive(Deserialize)]
2114struct ApiChatResponse {
2115 id: String,
2116 choices: Vec<ApiChoice>,
2117 model: String,
2118 usage: ApiUsage,
2119}
2120
2121#[derive(Deserialize)]
2122struct ApiChoice {
2123 message: ApiResponseMessage,
2124 finish_reason: Option<String>,
2125}
2126
2127#[derive(Deserialize)]
2128struct ApiResponseMessage {
2129 content: Option<String>,
2130 #[serde(default)]
2131 refusal: Option<String>,
2132 tool_calls: Option<Vec<ApiResponseToolCall>>,
2133 #[serde(default)]
2136 reasoning_content: Option<String>,
2137 #[serde(default)]
2140 reasoning: Option<String>,
2141}
2142
2143#[derive(Deserialize)]
2144struct ApiResponseToolCall {
2145 id: String,
2146 function: ApiResponseFunctionCall,
2147}
2148
2149#[derive(Deserialize)]
2150struct ApiResponseFunctionCall {
2151 name: String,
2152 arguments: String,
2153}
2154
2155#[derive(Deserialize)]
2156struct ApiUsage {
2157 #[serde(deserialize_with = "deserialize_u32_from_number")]
2158 prompt_tokens: u32,
2159 #[serde(deserialize_with = "deserialize_u32_from_number")]
2160 completion_tokens: u32,
2161 #[serde(default)]
2162 prompt_tokens_details: Option<ApiPromptTokensDetails>,
2163}
2164
2165#[derive(Deserialize)]
2166struct ApiPromptTokensDetails {
2167 #[serde(default, deserialize_with = "deserialize_u32_from_number")]
2168 cached_tokens: u32,
2169 #[serde(default, deserialize_with = "deserialize_u32_from_number")]
2170 cache_write_tokens: u32,
2171}
2172
2173struct ToolCallAccumulator {
2179 id: String,
2180 name: String,
2181 arguments: String,
2182}
2183
2184#[derive(Deserialize)]
2186struct SseChunk {
2187 #[serde(default)]
2191 choices: Vec<SseChoice>,
2192 #[serde(default)]
2193 usage: Option<SseUsage>,
2194}
2195
2196#[derive(Deserialize)]
2197struct SseChoice {
2198 delta: SseDelta,
2199 finish_reason: Option<String>,
2200}
2201
2202#[derive(Deserialize)]
2203struct SseDelta {
2204 content: Option<String>,
2205 #[serde(default)]
2206 refusal: Option<String>,
2207 tool_calls: Option<Vec<SseToolCallDelta>>,
2208 #[serde(default)]
2211 reasoning_content: Option<String>,
2212 #[serde(default)]
2215 reasoning: Option<String>,
2216}
2217
2218#[derive(Deserialize)]
2219struct SseToolCallDelta {
2220 index: usize,
2221 id: Option<String>,
2222 function: Option<SseFunctionDelta>,
2223}
2224
2225#[derive(Deserialize)]
2226struct SseFunctionDelta {
2227 name: Option<String>,
2228 arguments: Option<String>,
2229}
2230
2231#[derive(Deserialize)]
2232struct SseUsage {
2233 #[serde(deserialize_with = "deserialize_u32_from_number")]
2234 prompt_tokens: u32,
2235 #[serde(deserialize_with = "deserialize_u32_from_number")]
2236 completion_tokens: u32,
2237 #[serde(default)]
2238 prompt_tokens_details: Option<ApiPromptTokensDetails>,
2239}
2240
2241fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
2242where
2243 D: serde::Deserializer<'de>,
2244{
2245 #[derive(Deserialize)]
2246 #[serde(untagged)]
2247 enum NumberLike {
2248 U64(u64),
2249 F64(f64),
2250 }
2251
2252 match NumberLike::deserialize(deserializer)? {
2253 NumberLike::U64(v) => u32::try_from(v)
2254 .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
2255 NumberLike::F64(v) => {
2256 if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
2257 v.to_string().parse::<u32>().map_err(|e| {
2258 D::Error::custom(format!(
2259 "failed to convert integer-compatible token count {v} to u32: {e}"
2260 ))
2261 })
2262 } else {
2263 Err(D::Error::custom(format!(
2264 "token count must be a non-negative integer-compatible number, got {v}"
2265 )))
2266 }
2267 }
2268 }
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273 use super::*;
2274 use anyhow::Context as _;
2275
2276 const OPENAI_MODELS_FIXTURE: &str = r#"{
2277 "object": "list",
2278 "data": [
2279 {"id": "gpt-5.4", "object": "model", "owned_by": "openai"},
2280 {"id": "gpt-4o", "object": "model", "owned_by": "openai"}
2281 ]
2282 }"#;
2283
2284 fn function_tool(name: &str) -> agent_sdk_foundation::llm::Tool {
2285 agent_sdk_foundation::llm::Tool {
2286 name: name.to_owned(),
2287 description: format!("Call {name}"),
2288 input_schema: serde_json::json!({"type": "object"}),
2289 display_name: name.to_owned(),
2290 tier: agent_sdk_foundation::ToolTier::Observe,
2291 }
2292 }
2293
2294 #[test]
2295 fn parse_models_list_reads_ids() -> anyhow::Result<()> {
2296 let models = parse_models_list(OPENAI_MODELS_FIXTURE)?;
2297 assert_eq!(models.len(), 2);
2298 assert_eq!(models[0].id, "gpt-5.4");
2299 assert_eq!(models[1].id, "gpt-4o");
2300 assert_eq!(models[0].display_name, None);
2302 assert_eq!(models[0].context_window, None);
2303 Ok(())
2304 }
2305
2306 #[test]
2311 fn test_new_creates_provider_with_custom_model() {
2312 let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
2313
2314 assert_eq!(provider.model(), "custom-model");
2315 assert_eq!(provider.provider(), "openai");
2316 assert_eq!(provider.base_url, DEFAULT_BASE_URL);
2317 }
2318
2319 #[test]
2320 fn test_with_base_url_creates_provider_with_custom_url() {
2321 let provider = OpenAIProvider::with_base_url(
2322 "test-api-key".to_string(),
2323 "llama3".to_string(),
2324 "http://localhost:11434/v1".to_string(),
2325 );
2326
2327 assert_eq!(provider.model(), "llama3");
2328 assert_eq!(provider.base_url, "http://localhost:11434/v1");
2329 }
2330
2331 #[test]
2332 fn test_gpt4o_factory_creates_gpt4o_provider() {
2333 let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
2334
2335 assert_eq!(provider.model(), MODEL_GPT4O);
2336 assert_eq!(provider.provider(), "openai");
2337 }
2338
2339 #[test]
2340 fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
2341 let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
2342
2343 assert_eq!(provider.model(), MODEL_GPT4O_MINI);
2344 assert_eq!(provider.provider(), "openai");
2345 }
2346
2347 #[test]
2348 fn test_gpt52_thinking_factory_creates_provider() {
2349 let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
2350
2351 assert_eq!(provider.model(), MODEL_GPT52_THINKING);
2352 assert_eq!(provider.provider(), "openai");
2353 }
2354
2355 #[test]
2356 fn test_gpt54_factory_creates_provider() {
2357 let provider = OpenAIProvider::gpt54("test-api-key".to_string());
2358
2359 assert_eq!(provider.model(), MODEL_GPT54);
2360 assert_eq!(provider.provider(), "openai");
2361 }
2362
2363 #[test]
2364 fn test_gpt56_factories_create_expected_providers() {
2365 for (provider, expected_model) in [
2366 (
2367 OpenAIProvider::gpt56("test-api-key".to_string()),
2368 MODEL_GPT56,
2369 ),
2370 (
2371 OpenAIProvider::gpt56_sol("test-api-key".to_string()),
2372 MODEL_GPT56_SOL,
2373 ),
2374 (
2375 OpenAIProvider::gpt56_terra("test-api-key".to_string()),
2376 MODEL_GPT56_TERRA,
2377 ),
2378 (
2379 OpenAIProvider::gpt56_luna("test-api-key".to_string()),
2380 MODEL_GPT56_LUNA,
2381 ),
2382 ] {
2383 assert_eq!(provider.model(), expected_model);
2384 assert_eq!(provider.provider(), "openai");
2385 assert_eq!(provider.default_max_tokens(), 128_000);
2386 }
2387 }
2388
2389 #[test]
2390 fn test_gpt53_codex_factory_creates_provider() {
2391 let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
2392
2393 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2394 assert_eq!(provider.provider(), "openai");
2395 }
2396
2397 #[test]
2398 fn test_codex_factory_points_to_latest_codex_model() {
2399 let provider = OpenAIProvider::codex("test-api-key".to_string());
2400
2401 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2402 assert_eq!(provider.provider(), "openai");
2403 }
2404
2405 #[test]
2406 fn test_gpt5_factory_creates_gpt5_provider() {
2407 let provider = OpenAIProvider::gpt5("test-api-key".to_string());
2408
2409 assert_eq!(provider.model(), MODEL_GPT5);
2410 assert_eq!(provider.provider(), "openai");
2411 }
2412
2413 #[test]
2414 fn test_gpt5_mini_factory_creates_provider() {
2415 let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
2416
2417 assert_eq!(provider.model(), MODEL_GPT5_MINI);
2418 assert_eq!(provider.provider(), "openai");
2419 }
2420
2421 #[test]
2422 fn test_o3_factory_creates_o3_provider() {
2423 let provider = OpenAIProvider::o3("test-api-key".to_string());
2424
2425 assert_eq!(provider.model(), MODEL_O3);
2426 assert_eq!(provider.provider(), "openai");
2427 }
2428
2429 #[test]
2430 fn test_o4_mini_factory_creates_o4_mini_provider() {
2431 let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
2432
2433 assert_eq!(provider.model(), MODEL_O4_MINI);
2434 assert_eq!(provider.provider(), "openai");
2435 }
2436
2437 #[test]
2438 fn test_o1_factory_creates_o1_provider() {
2439 let provider = OpenAIProvider::o1("test-api-key".to_string());
2440
2441 assert_eq!(provider.model(), MODEL_O1);
2442 assert_eq!(provider.provider(), "openai");
2443 }
2444
2445 #[test]
2446 fn test_gpt41_factory_creates_gpt41_provider() {
2447 let provider = OpenAIProvider::gpt41("test-api-key".to_string());
2448
2449 assert_eq!(provider.model(), MODEL_GPT41);
2450 assert_eq!(provider.provider(), "openai");
2451 }
2452
2453 #[test]
2454 fn test_kimi_factory_creates_provider_with_kimi_base_url() {
2455 let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
2456
2457 assert_eq!(provider.model(), "kimi-custom");
2458 assert_eq!(provider.base_url, BASE_URL_KIMI);
2459 assert_eq!(provider.provider(), "openai");
2460 }
2461
2462 #[test]
2463 fn test_kimi_k2_5_factory_creates_provider() {
2464 let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
2465
2466 assert_eq!(provider.model(), MODEL_KIMI_K2_5);
2467 assert_eq!(provider.base_url, BASE_URL_KIMI);
2468 assert_eq!(provider.provider(), "openai");
2469 }
2470
2471 #[test]
2472 fn test_kimi_k2_thinking_factory_creates_provider() {
2473 let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
2474
2475 assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
2476 assert_eq!(provider.base_url, BASE_URL_KIMI);
2477 assert_eq!(provider.provider(), "openai");
2478 }
2479
2480 #[test]
2481 fn test_zai_factory_creates_provider_with_zai_base_url() {
2482 let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
2483
2484 assert_eq!(provider.model(), "glm-custom");
2485 assert_eq!(provider.base_url, BASE_URL_ZAI);
2486 assert_eq!(provider.provider(), "openai");
2487 }
2488
2489 #[test]
2490 fn test_zai_glm5_factory_creates_provider() {
2491 let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
2492
2493 assert_eq!(provider.model(), MODEL_ZAI_GLM5);
2494 assert_eq!(provider.base_url, BASE_URL_ZAI);
2495 assert_eq!(provider.provider(), "openai");
2496 }
2497
2498 #[test]
2499 fn test_minimax_factory_creates_provider_with_minimax_base_url() {
2500 let provider =
2501 OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
2502
2503 assert_eq!(provider.model(), "minimax-custom");
2504 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
2505 assert_eq!(provider.provider(), "openai");
2506 }
2507
2508 #[test]
2509 fn test_minimax_m2_5_factory_creates_provider() {
2510 let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
2511
2512 assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
2513 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
2514 assert_eq!(provider.provider(), "openai");
2515 }
2516
2517 #[test]
2522 fn test_model_constants_have_expected_values() {
2523 assert_eq!(MODEL_GPT56, "gpt-5.6");
2525 assert_eq!(MODEL_GPT56_SOL, "gpt-5.6-sol");
2526 assert_eq!(MODEL_GPT56_TERRA, "gpt-5.6-terra");
2527 assert_eq!(MODEL_GPT56_LUNA, "gpt-5.6-luna");
2528 assert_eq!(MODEL_GPT54, "gpt-5.4");
2530 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2531 assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
2533 assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
2534 assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
2535 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2536 assert_eq!(MODEL_GPT5, "gpt-5");
2538 assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
2539 assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
2540 assert_eq!(MODEL_O3, "o3");
2542 assert_eq!(MODEL_O3_MINI, "o3-mini");
2543 assert_eq!(MODEL_O4_MINI, "o4-mini");
2544 assert_eq!(MODEL_O1, "o1");
2545 assert_eq!(MODEL_O1_MINI, "o1-mini");
2546 assert_eq!(MODEL_GPT41, "gpt-4.1");
2548 assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
2549 assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
2550 assert_eq!(MODEL_GPT4O, "gpt-4o");
2552 assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
2553 assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
2555 assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
2556 assert_eq!(MODEL_ZAI_GLM5, "glm-5");
2557 assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
2558 assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
2559 assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
2560 assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
2561 }
2562
2563 #[test]
2568 fn test_provider_is_cloneable() {
2569 let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
2570 let cloned = provider.clone();
2571
2572 assert_eq!(provider.model(), cloned.model());
2573 assert_eq!(provider.provider(), cloned.provider());
2574 assert_eq!(provider.base_url, cloned.base_url);
2575 }
2576
2577 #[test]
2582 fn test_api_role_serialization() {
2583 let system_role = ApiRole::System;
2584 let user_role = ApiRole::User;
2585 let assistant_role = ApiRole::Assistant;
2586 let tool_role = ApiRole::Tool;
2587
2588 assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
2589 assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
2590 assert_eq!(
2591 serde_json::to_string(&assistant_role).unwrap(),
2592 "\"assistant\""
2593 );
2594 assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
2595 }
2596
2597 #[test]
2598 fn test_api_message_serialization_simple() {
2599 let message = ApiMessage {
2600 role: ApiRole::User,
2601 content: Some("Hello, world!".to_string()),
2602 reasoning_content: None,
2603 tool_calls: None,
2604 tool_call_id: None,
2605 prompt_cache_breakpoint: false,
2606 };
2607
2608 let json = serde_json::to_string(&message).unwrap();
2609 assert!(json.contains("\"role\":\"user\""));
2610 assert!(json.contains("\"content\":\"Hello, world!\""));
2611 assert!(!json.contains("tool_calls"));
2613 assert!(!json.contains("tool_call_id"));
2614 }
2615
2616 #[test]
2617 fn test_api_message_serialization_with_tool_calls() {
2618 let message = ApiMessage {
2619 role: ApiRole::Assistant,
2620 content: Some("Let me help.".to_string()),
2621 reasoning_content: None,
2622 tool_calls: Some(vec![ApiToolCall {
2623 id: "call_123".to_string(),
2624 r#type: "function".to_string(),
2625 function: ApiFunctionCall {
2626 name: "read_file".to_string(),
2627 arguments: "{\"path\": \"/test.txt\"}".to_string(),
2628 },
2629 }]),
2630 tool_call_id: None,
2631 prompt_cache_breakpoint: false,
2632 };
2633
2634 let json = serde_json::to_string(&message).unwrap();
2635 assert!(json.contains("\"role\":\"assistant\""));
2636 assert!(json.contains("\"tool_calls\""));
2637 assert!(json.contains("\"id\":\"call_123\""));
2638 assert!(json.contains("\"type\":\"function\""));
2639 assert!(json.contains("\"name\":\"read_file\""));
2640 }
2641
2642 #[test]
2643 fn test_api_tool_message_serialization() {
2644 let message = ApiMessage {
2645 role: ApiRole::Tool,
2646 content: Some("File contents here".to_string()),
2647 reasoning_content: None,
2648 tool_calls: None,
2649 tool_call_id: Some("call_123".to_string()),
2650 prompt_cache_breakpoint: false,
2651 };
2652
2653 let json = serde_json::to_string(&message).unwrap();
2654 assert!(json.contains("\"role\":\"tool\""));
2655 assert!(json.contains("\"tool_call_id\":\"call_123\""));
2656 assert!(json.contains("\"content\":\"File contents here\""));
2657 }
2658
2659 #[test]
2660 fn test_api_tool_serialization() {
2661 let tool = ApiTool {
2662 r#type: "function".to_string(),
2663 function: ApiFunction {
2664 name: "test_tool".to_string(),
2665 description: "A test tool".to_string(),
2666 parameters: serde_json::json!({
2667 "type": "object",
2668 "properties": {
2669 "arg": {"type": "string"}
2670 }
2671 }),
2672 strict: Some(true),
2673 },
2674 };
2675
2676 let json = serde_json::to_string(&tool).unwrap();
2677 assert!(json.contains("\"type\":\"function\""));
2678 assert!(json.contains("\"name\":\"test_tool\""));
2679 assert!(json.contains("\"description\":\"A test tool\""));
2680 assert!(json.contains("\"parameters\""));
2681 }
2682
2683 #[test]
2688 fn test_api_response_deserialization() {
2689 let json = r#"{
2690 "id": "chatcmpl-123",
2691 "choices": [
2692 {
2693 "message": {
2694 "content": "Hello!"
2695 },
2696 "finish_reason": "stop"
2697 }
2698 ],
2699 "model": "gpt-4o",
2700 "usage": {
2701 "prompt_tokens": 100,
2702 "completion_tokens": 50
2703 }
2704 }"#;
2705
2706 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2707 assert_eq!(response.id, "chatcmpl-123");
2708 assert_eq!(response.model, "gpt-4o");
2709 assert_eq!(response.usage.prompt_tokens, 100);
2710 assert_eq!(response.usage.completion_tokens, 50);
2711 assert_eq!(response.choices.len(), 1);
2712 assert_eq!(
2713 response.choices[0].message.content,
2714 Some("Hello!".to_string())
2715 );
2716 }
2717
2718 #[test]
2719 fn structured_refusal_is_visible_and_sets_refusal_stop_reason() -> anyhow::Result<()> {
2720 let body = serde_json::json!({
2721 "id": "chatcmpl-refusal",
2722 "choices": [{
2723 "message": {
2724 "content": null,
2725 "refusal": "I cannot help with that."
2726 },
2727 "finish_reason": "stop"
2728 }],
2729 "model": "gpt-5.6",
2730 "usage": {
2731 "prompt_tokens": 12,
2732 "completion_tokens": 6
2733 }
2734 });
2735 let bytes = serde_json::to_vec(&body)?;
2736 let outcome = decode_chat_response(StatusCode::OK, &bytes, None)?;
2737 let ChatOutcome::Success(response) = outcome else {
2738 anyhow::bail!("structured refusal did not decode as a successful provider response");
2739 };
2740
2741 assert_eq!(response.first_text(), Some("I cannot help with that."));
2742 assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2743 Ok(())
2744 }
2745
2746 #[test]
2747 fn non_tool_terminal_suppresses_nonstream_tool_calls() -> anyhow::Result<()> {
2748 let body = serde_json::json!({
2749 "id": "chatcmpl-truncated-tool",
2750 "choices": [{
2751 "message": {
2752 "content": "partial",
2753 "tool_calls": [{
2754 "id": "call_partial",
2755 "type": "function",
2756 "function": {
2757 "name": "delete_record",
2758 "arguments": "{\"id\":"
2759 }
2760 }]
2761 },
2762 "finish_reason": "length"
2763 }],
2764 "model": "gpt-5.6",
2765 "usage": {"prompt_tokens": 12, "completion_tokens": 6}
2766 });
2767 let bytes = serde_json::to_vec(&body)?;
2768 let ChatOutcome::Success(response) = decode_chat_response(StatusCode::OK, &bytes, None)?
2769 else {
2770 anyhow::bail!("truncated response did not decode as a successful provider response");
2771 };
2772
2773 assert_eq!(response.stop_reason, Some(StopReason::MaxTokens));
2774 assert_eq!(response.first_text(), Some("partial"));
2775 assert!(!response.has_tool_use());
2776 Ok(())
2777 }
2778
2779 #[test]
2780 fn test_api_response_with_tool_calls_deserialization() {
2781 let json = r#"{
2782 "id": "chatcmpl-456",
2783 "choices": [
2784 {
2785 "message": {
2786 "content": null,
2787 "tool_calls": [
2788 {
2789 "id": "call_abc",
2790 "type": "function",
2791 "function": {
2792 "name": "read_file",
2793 "arguments": "{\"path\": \"test.txt\"}"
2794 }
2795 }
2796 ]
2797 },
2798 "finish_reason": "tool_calls"
2799 }
2800 ],
2801 "model": "gpt-4o",
2802 "usage": {
2803 "prompt_tokens": 150,
2804 "completion_tokens": 30
2805 }
2806 }"#;
2807
2808 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2809 let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
2810 assert_eq!(tool_calls.len(), 1);
2811 assert_eq!(tool_calls[0].id, "call_abc");
2812 assert_eq!(tool_calls[0].function.name, "read_file");
2813 }
2814
2815 #[test]
2816 fn test_api_response_with_unknown_finish_reason_deserialization() {
2817 let json = r#"{
2818 "id": "chatcmpl-789",
2819 "choices": [
2820 {
2821 "message": {
2822 "content": "ok"
2823 },
2824 "finish_reason": "vendor_custom_reason"
2825 }
2826 ],
2827 "model": "glm-5",
2828 "usage": {
2829 "prompt_tokens": 10,
2830 "completion_tokens": 5
2831 }
2832 }"#;
2833
2834 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2835 assert_eq!(
2836 response.choices[0].finish_reason.as_deref(),
2837 Some("vendor_custom_reason")
2838 );
2839 assert_eq!(
2840 map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
2841 StopReason::Unknown
2842 );
2843 }
2844
2845 #[test]
2846 fn test_map_finish_reason_covers_vendor_specific_values() {
2847 assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
2848 assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
2849 assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
2850 assert_eq!(map_finish_reason("content_filter"), StopReason::Refusal);
2851 assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
2852 assert_eq!(map_finish_reason("refusal"), StopReason::Refusal);
2853 assert_eq!(map_finish_reason("network_error"), StopReason::Unknown);
2854 assert_eq!(map_finish_reason("some_new_reason"), StopReason::Unknown);
2855 }
2856
2857 #[test]
2862 fn test_build_api_messages_with_system() {
2863 let request = ChatRequest {
2864 system: "You are helpful.".to_string(),
2865 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2866 tools: None,
2867 max_tokens: 1024,
2868 max_tokens_explicit: true,
2869 session_id: None,
2870 cached_content: None,
2871 thinking: None,
2872 tool_choice: None,
2873 response_format: None,
2874 cache: None,
2875 };
2876
2877 let api_messages = build_api_messages(&request);
2878 assert_eq!(api_messages.len(), 2);
2879 assert_eq!(api_messages[0].role, ApiRole::System);
2880 assert_eq!(
2881 api_messages[0].content,
2882 Some("You are helpful.".to_string())
2883 );
2884 assert_eq!(api_messages[1].role, ApiRole::User);
2885 assert_eq!(api_messages[1].content, Some("Hello".to_string()));
2886 }
2887
2888 #[test]
2889 fn test_build_api_messages_empty_system() {
2890 let request = ChatRequest {
2891 system: String::new(),
2892 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2893 tools: None,
2894 max_tokens: 1024,
2895 max_tokens_explicit: true,
2896 session_id: None,
2897 cached_content: None,
2898 thinking: None,
2899 tool_choice: None,
2900 response_format: None,
2901 cache: None,
2902 };
2903
2904 let api_messages = build_api_messages(&request);
2905 assert_eq!(api_messages.len(), 1);
2906 assert_eq!(api_messages[0].role, ApiRole::User);
2907 }
2908
2909 fn request_with_messages(messages: Vec<agent_sdk_foundation::llm::Message>) -> ChatRequest {
2910 ChatRequest {
2911 system: String::new(),
2912 messages,
2913 tools: None,
2914 max_tokens: 1024,
2915 max_tokens_explicit: true,
2916 session_id: None,
2917 cached_content: None,
2918 thinking: None,
2919 tool_choice: None,
2920 response_format: None,
2921 cache: None,
2922 }
2923 }
2924
2925 #[test]
2926 fn test_build_api_messages_echoes_assistant_reasoning_content_on_tool_call()
2927 -> anyhow::Result<()> {
2928 let request = request_with_messages(vec![
2932 agent_sdk_foundation::llm::Message::user("What is the weather?"),
2933 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
2934 ContentBlock::Thinking {
2935 thinking: "I should call the weather tool.".to_string(),
2936 signature: None,
2937 },
2938 ContentBlock::ToolUse {
2939 id: "call_1".to_string(),
2940 name: "get_weather".to_string(),
2941 input: serde_json::json!({"city": "Paris"}),
2942 thought_signature: None,
2943 },
2944 ]),
2945 ]);
2946
2947 let api_messages = build_api_messages(&request);
2948 let assistant = api_messages
2949 .iter()
2950 .find(|m| m.role == ApiRole::Assistant)
2951 .context("assistant message present")?;
2952 assert!(assistant.tool_calls.is_some());
2953 assert_eq!(
2954 assistant.reasoning_content,
2955 Some("I should call the weather tool.".to_string())
2956 );
2957 Ok(())
2958 }
2959
2960 #[test]
2961 fn test_build_api_messages_reasoning_content_serializes_on_tool_call_turn() -> anyhow::Result<()>
2962 {
2963 let request = request_with_messages(vec![
2964 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
2965 ContentBlock::Thinking {
2966 thinking: "thinking out loud".to_string(),
2967 signature: None,
2968 },
2969 ContentBlock::ToolUse {
2970 id: "call_1".to_string(),
2971 name: "do_thing".to_string(),
2972 input: serde_json::json!({}),
2973 thought_signature: None,
2974 },
2975 ]),
2976 ]);
2977
2978 let api_messages = build_api_messages(&request);
2979 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
2980 assert!(json.contains("\"reasoning_content\":\"thinking out loud\""));
2981 Ok(())
2982 }
2983
2984 #[test]
2985 fn test_build_api_messages_reasoning_only_turn_is_not_echoed() -> anyhow::Result<()> {
2986 let request = request_with_messages(vec![
2992 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
2993 ContentBlock::Thinking {
2994 thinking: "pondering".to_string(),
2995 signature: None,
2996 },
2997 ]),
2998 ]);
2999
3000 let api_messages = build_api_messages(&request);
3001 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3002 assert!(!json.contains("reasoning_content"));
3003 assert!(api_messages.is_empty());
3004 Ok(())
3005 }
3006
3007 #[test]
3008 fn test_build_api_messages_reasoning_with_text_no_tool_call_is_not_echoed() -> anyhow::Result<()>
3009 {
3010 let request = request_with_messages(vec![
3013 agent_sdk_foundation::llm::Message::user("What is 2+2?"),
3014 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
3015 ContentBlock::Thinking {
3016 thinking: "Let me add 2 and 2.".to_string(),
3017 signature: None,
3018 },
3019 ContentBlock::Text {
3020 text: "4".to_string(),
3021 },
3022 ]),
3023 agent_sdk_foundation::llm::Message::user("And 3+3?"),
3024 ]);
3025
3026 let api_messages = build_api_messages(&request);
3027 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3028 assert!(!json.contains("reasoning_content"));
3029 let assistant = api_messages
3030 .iter()
3031 .find(|m| m.role == ApiRole::Assistant)
3032 .context("assistant message present")?;
3033 assert_eq!(assistant.content, Some("4".to_string()));
3034 assert_eq!(assistant.reasoning_content, None);
3035 Ok(())
3036 }
3037
3038 #[test]
3039 fn test_build_api_messages_normal_path_has_no_reasoning_content() -> anyhow::Result<()> {
3040 let request = request_with_messages(vec![
3043 agent_sdk_foundation::llm::Message::user("hi"),
3044 agent_sdk_foundation::llm::Message::assistant_with_content(vec![ContentBlock::Text {
3045 text: "hello".to_string(),
3046 }]),
3047 ]);
3048
3049 let api_messages = build_api_messages(&request);
3050 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3051 assert!(!json.contains("reasoning_content"));
3052 let assistant = api_messages
3053 .iter()
3054 .find(|m| m.role == ApiRole::Assistant)
3055 .context("assistant message present")?;
3056 assert_eq!(assistant.reasoning_content, None);
3057 Ok(())
3058 }
3059
3060 #[test]
3061 fn test_build_api_messages_does_not_attach_reasoning_to_user_blocks() {
3062 let request =
3065 request_with_messages(vec![agent_sdk_foundation::llm::Message::user_with_content(
3066 vec![
3067 ContentBlock::Thinking {
3068 thinking: "user-side thinking".to_string(),
3069 signature: None,
3070 },
3071 ContentBlock::Text {
3072 text: "question".to_string(),
3073 },
3074 ],
3075 )]);
3076
3077 let api_messages = build_api_messages(&request);
3078 assert_eq!(api_messages.len(), 1);
3079 assert_eq!(api_messages[0].role, ApiRole::User);
3080 assert_eq!(api_messages[0].reasoning_content, None);
3081 }
3082
3083 #[test]
3084 fn test_convert_tool() {
3085 let tool = agent_sdk_foundation::llm::Tool {
3086 name: "test_tool".to_string(),
3087 description: "A test tool".to_string(),
3088 input_schema: serde_json::json!({
3089 "type": "object",
3090 "properties": {"value": {"type": "string"}}
3091 }),
3092 display_name: "Test Tool".to_string(),
3093 tier: agent_sdk_foundation::ToolTier::Observe,
3094 };
3095
3096 let compatible_tool = convert_tool(tool.clone(), false);
3097 assert!(compatible_tool.function.strict.is_none());
3098 assert_eq!(
3099 compatible_tool.function.parameters,
3100 serde_json::json!({
3101 "type": "object",
3102 "properties": {"value": {"type": "string"}}
3103 })
3104 );
3105
3106 let api_tool = convert_tool(tool, true);
3107 assert_eq!(api_tool.r#type, "function");
3108 assert_eq!(api_tool.function.name, "test_tool");
3109 assert_eq!(api_tool.function.description, "A test tool");
3110 assert_eq!(api_tool.function.strict, Some(true));
3111 assert_eq!(api_tool.function.parameters["additionalProperties"], false);
3112 }
3113
3114 #[test]
3115 fn test_build_content_blocks_text_only() {
3116 let message = ApiResponseMessage {
3117 content: Some("Hello!".to_string()),
3118 refusal: None,
3119 tool_calls: None,
3120 reasoning_content: None,
3121 reasoning: None,
3122 };
3123
3124 let blocks = build_content_blocks(&message);
3125 assert_eq!(blocks.len(), 1);
3126 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
3127 }
3128
3129 #[test]
3130 fn test_build_content_blocks_with_tool_calls() {
3131 let message = ApiResponseMessage {
3132 content: Some("Let me help.".to_string()),
3133 refusal: None,
3134 tool_calls: Some(vec![ApiResponseToolCall {
3135 id: "call_123".to_string(),
3136 function: ApiResponseFunctionCall {
3137 name: "read_file".to_string(),
3138 arguments: "{\"path\": \"test.txt\"}".to_string(),
3139 },
3140 }]),
3141 reasoning_content: None,
3142 reasoning: None,
3143 };
3144
3145 let blocks = build_content_blocks(&message);
3146 assert_eq!(blocks.len(), 2);
3147 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
3148 assert!(
3149 matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
3150 );
3151 }
3152
3153 #[test]
3154 fn test_build_content_blocks_falls_back_to_reasoning_content_when_content_empty() {
3155 let message = ApiResponseMessage {
3158 content: None,
3159 refusal: None,
3160 tool_calls: None,
3161 reasoning_content: Some("The answer is 42.".to_string()),
3162 reasoning: None,
3163 };
3164
3165 let blocks = build_content_blocks(&message);
3166 assert_eq!(blocks.len(), 1);
3167 assert!(
3168 matches!(&blocks[0], ContentBlock::Thinking { thinking, signature } if thinking == "The answer is 42." && signature.is_none())
3169 );
3170 }
3171
3172 #[test]
3173 fn test_build_content_blocks_falls_back_to_reasoning_field() {
3174 let message = ApiResponseMessage {
3176 content: Some(String::new()),
3177 refusal: None,
3178 tool_calls: None,
3179 reasoning_content: None,
3180 reasoning: Some("Considering options...".to_string()),
3181 };
3182
3183 let blocks = build_content_blocks(&message);
3184 assert_eq!(blocks.len(), 1);
3185 assert!(
3186 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "Considering options...")
3187 );
3188 }
3189
3190 #[test]
3191 fn test_build_content_blocks_prefers_reasoning_content_over_reasoning() {
3192 let message = ApiResponseMessage {
3193 content: None,
3194 refusal: None,
3195 tool_calls: None,
3196 reasoning_content: Some("primary".to_string()),
3197 reasoning: Some("secondary".to_string()),
3198 };
3199
3200 let blocks = build_content_blocks(&message);
3201 assert_eq!(blocks.len(), 1);
3202 assert!(
3203 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "primary")
3204 );
3205 }
3206
3207 #[test]
3208 fn test_build_content_blocks_does_not_add_reasoning_when_content_present() {
3209 let message = ApiResponseMessage {
3212 content: Some("Final answer.".to_string()),
3213 refusal: None,
3214 tool_calls: None,
3215 reasoning_content: Some("internal chain of thought".to_string()),
3216 reasoning: None,
3217 };
3218
3219 let blocks = build_content_blocks(&message);
3220 assert_eq!(blocks.len(), 1);
3221 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Final answer."));
3222 }
3223
3224 #[test]
3225 fn test_build_content_blocks_reasoning_fallback_with_tool_calls() {
3226 let message = ApiResponseMessage {
3229 content: None,
3230 refusal: None,
3231 tool_calls: Some(vec![ApiResponseToolCall {
3232 id: "call_1".to_string(),
3233 function: ApiResponseFunctionCall {
3234 name: "search".to_string(),
3235 arguments: "{}".to_string(),
3236 },
3237 }]),
3238 reasoning_content: Some("I should search.".to_string()),
3239 reasoning: None,
3240 };
3241
3242 let blocks = build_content_blocks(&message);
3243 assert_eq!(blocks.len(), 2);
3244 assert!(
3245 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "I should search.")
3246 );
3247 assert!(matches!(&blocks[1], ContentBlock::ToolUse { name, .. } if name == "search"));
3248 }
3249
3250 #[test]
3251 fn test_build_content_blocks_empty_message_yields_no_blocks() {
3252 let message = ApiResponseMessage {
3255 content: None,
3256 refusal: None,
3257 tool_calls: None,
3258 reasoning_content: None,
3259 reasoning: None,
3260 };
3261
3262 let blocks = build_content_blocks(&message);
3263 assert!(blocks.is_empty());
3264 }
3265
3266 #[test]
3267 fn test_api_response_message_deserializes_reasoning_content() {
3268 let json = r#"{
3269 "content": null,
3270 "reasoning_content": "step by step"
3271 }"#;
3272
3273 let message: ApiResponseMessage = serde_json::from_str(json).unwrap();
3274 assert_eq!(reasoning_text(&message), Some("step by step"));
3275 assert!(message.content.is_none());
3276 }
3277
3278 #[test]
3283 fn test_sse_chunk_text_delta_deserialization() {
3284 let json = r#"{
3285 "choices": [{
3286 "delta": {
3287 "content": "Hello"
3288 },
3289 "finish_reason": null
3290 }]
3291 }"#;
3292
3293 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3294 assert_eq!(chunk.choices.len(), 1);
3295 assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
3296 assert!(chunk.choices[0].finish_reason.is_none());
3297 }
3298
3299 #[test]
3300 fn test_sse_chunk_tool_call_delta_deserialization() {
3301 let json = r#"{
3302 "choices": [{
3303 "delta": {
3304 "tool_calls": [{
3305 "index": 0,
3306 "id": "call_abc",
3307 "function": {
3308 "name": "read_file",
3309 "arguments": ""
3310 }
3311 }]
3312 },
3313 "finish_reason": null
3314 }]
3315 }"#;
3316
3317 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3318 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
3319 assert_eq!(tool_calls.len(), 1);
3320 assert_eq!(tool_calls[0].index, 0);
3321 assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
3322 assert_eq!(
3323 tool_calls[0].function.as_ref().unwrap().name,
3324 Some("read_file".to_string())
3325 );
3326 }
3327
3328 #[test]
3329 fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
3330 let json = r#"{
3331 "choices": [{
3332 "delta": {
3333 "tool_calls": [{
3334 "index": 0,
3335 "function": {
3336 "arguments": "{\"path\":"
3337 }
3338 }]
3339 },
3340 "finish_reason": null
3341 }]
3342 }"#;
3343
3344 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3345 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
3346 assert_eq!(tool_calls[0].id, None);
3347 assert_eq!(
3348 tool_calls[0].function.as_ref().unwrap().arguments,
3349 Some("{\"path\":".to_string())
3350 );
3351 }
3352
3353 #[test]
3354 fn test_sse_chunk_with_finish_reason_deserialization() {
3355 let json = r#"{
3356 "choices": [{
3357 "delta": {},
3358 "finish_reason": "stop"
3359 }]
3360 }"#;
3361
3362 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3363 assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
3364 }
3365
3366 #[test]
3367 fn test_sse_chunk_with_usage_deserialization() {
3368 let json = r#"{
3369 "choices": [{
3370 "delta": {},
3371 "finish_reason": "stop"
3372 }],
3373 "usage": {
3374 "prompt_tokens": 100,
3375 "completion_tokens": 50
3376 }
3377 }"#;
3378
3379 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3380 let usage = chunk.usage.unwrap();
3381 assert_eq!(usage.prompt_tokens, 100);
3382 assert_eq!(usage.completion_tokens, 50);
3383 }
3384
3385 #[test]
3386 fn test_sse_chunk_with_float_usage_deserialization() {
3387 let json = r#"{
3388 "choices": [{
3389 "delta": {},
3390 "finish_reason": "stop"
3391 }],
3392 "usage": {
3393 "prompt_tokens": 100.0,
3394 "completion_tokens": 50.0
3395 }
3396 }"#;
3397
3398 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3399 let usage = chunk.usage.unwrap();
3400 assert_eq!(usage.prompt_tokens, 100);
3401 assert_eq!(usage.completion_tokens, 50);
3402 }
3403
3404 #[test]
3405 fn test_api_usage_deserializes_integer_compatible_numbers() {
3406 let json = r#"{
3407 "prompt_tokens": 42.0,
3408 "completion_tokens": 7
3409 }"#;
3410
3411 let usage: ApiUsage = serde_json::from_str(json).unwrap();
3412 assert_eq!(usage.prompt_tokens, 42);
3413 assert_eq!(usage.completion_tokens, 7);
3414 }
3415
3416 #[test]
3417 fn test_api_usage_deserializes_cache_read_and_write_tokens() -> anyhow::Result<()> {
3418 let json = r#"{
3419 "prompt_tokens": 42,
3420 "completion_tokens": 7,
3421 "prompt_tokens_details": {
3422 "cached_tokens": 10,
3423 "cache_write_tokens": 6
3424 }
3425 }"#;
3426
3427 let usage: ApiUsage = serde_json::from_str(json)?;
3428 assert_eq!(usage.prompt_tokens, 42);
3429 assert_eq!(usage.completion_tokens, 7);
3430 let details = usage
3431 .prompt_tokens_details
3432 .context("prompt token details missing")?;
3433 assert_eq!(details.cached_tokens, 10);
3434 assert_eq!(details.cache_write_tokens, 6);
3435 Ok(())
3436 }
3437
3438 #[test]
3439 fn test_process_sse_data_maps_cache_read_and_write_usage() {
3440 let results = process_sse_data(
3441 r#"{
3442 "choices": [],
3443 "usage": {
3444 "prompt_tokens": 42,
3445 "completion_tokens": 7,
3446 "prompt_tokens_details": {
3447 "cached_tokens": 10,
3448 "cache_write_tokens": 6
3449 }
3450 }
3451 }"#,
3452 );
3453
3454 assert!(matches!(
3455 results.as_slice(),
3456 [SseProcessResult::Usage(Usage {
3457 input_tokens: 42,
3458 output_tokens: 7,
3459 cached_input_tokens: 10,
3460 cache_creation_input_tokens: 6,
3461 })]
3462 ));
3463 }
3464
3465 #[test]
3466 fn test_sse_delta_deserializes_reasoning_fields() -> anyhow::Result<()> {
3467 let chunk: SseChunk = serde_json::from_str(
3471 r#"{
3472 "choices": [{
3473 "delta": {
3474 "reasoning_content": "step one"
3475 },
3476 "finish_reason": null
3477 }]
3478 }"#,
3479 )
3480 .context("deserialize sse chunk")?;
3481 assert_eq!(
3482 chunk.choices[0].delta.reasoning_content,
3483 Some("step one".to_string())
3484 );
3485 assert!(chunk.choices[0].delta.content.is_none());
3486 Ok(())
3487 }
3488
3489 #[test]
3490 fn test_process_sse_data_emits_thinking_delta_from_reasoning_content() {
3491 let results = process_sse_data(
3496 r#"{
3497 "choices": [{
3498 "delta": { "reasoning_content": "thinking..." },
3499 "finish_reason": null
3500 }]
3501 }"#,
3502 );
3503
3504 assert!(matches!(
3505 results.as_slice(),
3506 [SseProcessResult::ThinkingDelta(text)] if text == "thinking..."
3507 ));
3508 }
3509
3510 #[test]
3511 fn test_process_sse_data_emits_thinking_delta_from_reasoning_field() {
3512 let results = process_sse_data(
3514 r#"{
3515 "choices": [{
3516 "delta": { "reasoning": "pondering" },
3517 "finish_reason": null
3518 }]
3519 }"#,
3520 );
3521
3522 assert!(matches!(
3523 results.as_slice(),
3524 [SseProcessResult::ThinkingDelta(text)] if text == "pondering"
3525 ));
3526 }
3527
3528 #[test]
3529 fn test_process_sse_data_prefers_text_content_over_reasoning() {
3530 let results = process_sse_data(
3533 r#"{
3534 "choices": [{
3535 "delta": {
3536 "content": "answer",
3537 "reasoning_content": "ignored"
3538 },
3539 "finish_reason": null
3540 }]
3541 }"#,
3542 );
3543
3544 assert!(matches!(
3545 results.as_slice(),
3546 [SseProcessResult::TextDelta(text)] if text == "answer"
3547 ));
3548 }
3549
3550 #[test]
3551 fn test_process_sse_data_empty_content_falls_back_to_reasoning() {
3552 let results = process_sse_data(
3555 r#"{
3556 "choices": [{
3557 "delta": {
3558 "content": "",
3559 "reasoning_content": "fallback"
3560 },
3561 "finish_reason": null
3562 }]
3563 }"#,
3564 );
3565
3566 assert!(matches!(
3567 results.as_slice(),
3568 [SseProcessResult::ThinkingDelta(text)] if text == "fallback"
3569 ));
3570 }
3571
3572 #[test]
3573 fn streamed_refusal_is_visible_and_preserves_refusal_stop_reason() -> anyhow::Result<()> {
3574 let mut tool_calls = HashMap::new();
3575 let mut usage = None;
3576 let mut stop_reason = None;
3577 let chunk = step_completion_stream(
3578 r#"{"choices":[{"delta":{"refusal":"Cannot comply."},"finish_reason":"stop"}]}"#,
3579 &mut tool_calls,
3580 &mut usage,
3581 &mut stop_reason,
3582 );
3583
3584 assert!(matches!(
3585 chunk.immediate.as_slice(),
3586 [StreamDelta::TextDelta { delta, .. }] if delta == "Cannot comply."
3587 ));
3588 assert_eq!(stop_reason, Some(StopReason::Refusal));
3589
3590 let done = step_completion_stream("[DONE]", &mut tool_calls, &mut usage, &mut stop_reason)
3591 .terminal
3592 .context("[DONE] did not finalize refusal stream")?;
3593 assert!(done.iter().any(|delta| matches!(
3594 delta,
3595 StreamDelta::Done {
3596 stop_reason: Some(StopReason::Refusal)
3597 }
3598 )));
3599 Ok(())
3600 }
3601
3602 #[test]
3603 fn test_api_usage_rejects_fractional_numbers() {
3604 let json = r#"{
3605 "prompt_tokens": 42.5,
3606 "completion_tokens": 7
3607 }"#;
3608
3609 let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
3610 assert!(usage.is_err());
3611 }
3612
3613 #[test]
3614 fn test_use_max_tokens_alias_for_vendor_urls() {
3615 assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
3616 assert!(use_max_tokens_alias(BASE_URL_KIMI));
3617 assert!(use_max_tokens_alias(BASE_URL_ZAI));
3618 assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
3619 }
3620
3621 #[test]
3622 fn test_requires_responses_api_for_codex_models() {
3623 assert!(requires_responses_api(MODEL_GPT52_CODEX));
3624 assert!(requires_responses_api(MODEL_GPT52_PRO));
3625 assert!(requires_responses_api(MODEL_GPT53_CODEX));
3626 assert!(!requires_responses_api(MODEL_GPT54));
3627 assert!(!requires_responses_api(MODEL_GPT56));
3628 assert!(!requires_responses_api(MODEL_GPT56_SOL));
3629 assert!(!requires_responses_api(MODEL_GPT56_TERRA));
3630 assert!(!requires_responses_api(MODEL_GPT56_LUNA));
3631 }
3632
3633 #[test]
3634 fn test_should_use_responses_api_for_official_agentic_requests() {
3635 let request = ChatRequest {
3636 system: String::new(),
3637 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
3638 tools: Some(vec![agent_sdk_foundation::llm::Tool {
3639 name: "read_file".to_string(),
3640 description: "Read a file".to_string(),
3641 input_schema: serde_json::json!({"type": "object"}),
3642 display_name: "Read File".to_string(),
3643 tier: agent_sdk_foundation::ToolTier::Observe,
3644 }]),
3645 max_tokens: 1024,
3646 max_tokens_explicit: true,
3647 session_id: Some("thread-1".to_string()),
3648 cached_content: None,
3649 thinking: None,
3650 tool_choice: None,
3651 response_format: None,
3652 cache: None,
3653 };
3654
3655 for model in [
3656 MODEL_GPT54,
3657 MODEL_GPT56,
3658 MODEL_GPT56_SOL,
3659 MODEL_GPT56_TERRA,
3660 MODEL_GPT56_LUNA,
3661 ] {
3662 assert!(should_use_responses_api(
3663 DEFAULT_BASE_URL,
3664 model,
3665 &request,
3666 None
3667 ));
3668 }
3669 assert!(!should_use_responses_api(
3670 BASE_URL_KIMI,
3671 MODEL_GPT54,
3672 &request,
3673 None,
3674 ));
3675 }
3676
3677 #[test]
3678 fn official_gpt56_auto_routes_but_custom_base_url_does_not() {
3679 let request = ChatRequest::new(String::new(), vec![]);
3680 assert!(should_use_responses_api(
3681 DEFAULT_BASE_URL,
3682 MODEL_GPT56,
3683 &request,
3684 None,
3685 ));
3686 assert!(!should_use_responses_api(
3687 "https://gateway.example/v1",
3688 MODEL_GPT56,
3689 &request,
3690 None,
3691 ));
3692 }
3693
3694 #[test]
3695 fn response_only_reasoning_controls_trigger_reroute() {
3696 let request = ChatRequest::new(String::new(), vec![]);
3697 let reasoning = OpenAIReasoningConfig::new()
3698 .with_mode(super::super::openai_reasoning::OpenAIReasoningMode::Pro);
3699 assert!(should_use_responses_api(
3700 "https://gateway.example/v1",
3701 MODEL_GPT56,
3702 &request,
3703 Some(&reasoning),
3704 ));
3705
3706 let forced_chat = reasoning.with_api_surface(OpenAIApiSurface::ChatCompletions);
3707 assert!(!should_use_responses_api(
3708 "https://gateway.example/v1",
3709 MODEL_GPT56,
3710 &request,
3711 Some(&forced_chat),
3712 ));
3713 assert!(OpenAIProvider::validate_chat_reasoning_controls(Some(&forced_chat)).is_err());
3714
3715 let codex = OpenAIProvider::gpt53_codex("test-key".to_owned()).with_reasoning(
3716 OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions),
3717 );
3718 assert!(codex.validate_requested_api_surface().is_err());
3719 }
3720
3721 #[test]
3722 fn openai_responses_history_reroutes_auto_and_forced_chat_rejects() {
3723 let request = ChatRequest::new(
3724 String::new(),
3725 vec![agent_sdk_foundation::llm::Message::assistant_with_content(
3726 vec![ContentBlock::OpaqueReasoning {
3727 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
3728 data: serde_json::json!({
3729 "type": "reasoning",
3730 "id": "rs_1",
3731 "encrypted_content": "ciphertext"
3732 }),
3733 }],
3734 )],
3735 );
3736
3737 assert!(should_use_responses_api(
3738 "https://gateway.example/v1",
3739 MODEL_GPT54,
3740 &request,
3741 None,
3742 ));
3743
3744 let forced_chat =
3745 OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions);
3746 assert!(!should_use_responses_api(
3747 "https://gateway.example/v1",
3748 MODEL_GPT54,
3749 &request,
3750 Some(&forced_chat),
3751 ));
3752 assert!(OpenAIProvider::validate_chat_opaque_reasoning(&request).is_err());
3753 }
3754
3755 #[test]
3756 fn exact_chat_tool_choice_maps_and_validates() -> anyhow::Result<()> {
3757 let tools = vec![function_tool("read_file")];
3758
3759 let required = OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::Required);
3760 let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&required), Some(&tools))?
3761 .context("required tool choice was omitted")?;
3762 assert_eq!(serde_json::to_value(choice)?, serde_json::json!("required"));
3763
3764 let forced = OpenAIReasoningConfig::new()
3765 .with_tool_choice(OpenAIToolChoice::Function("read_file".to_owned()));
3766 let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&forced), Some(&tools))?
3767 .context("forced tool choice was omitted")?;
3768 assert_eq!(
3769 serde_json::to_value(choice)?,
3770 serde_json::json!({"type": "function", "function": {"name": "read_file"}})
3771 );
3772
3773 let allowed =
3774 OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::AllowedTools {
3775 mode: OpenAIAllowedToolsMode::Required,
3776 tools: vec!["read_file".to_owned()],
3777 });
3778 let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&allowed), Some(&tools))?
3779 .context("allowed-tools choice was omitted")?;
3780 assert_eq!(
3781 serde_json::to_value(choice)?,
3782 serde_json::json!({
3783 "type": "allowed_tools",
3784 "allowed_tools": {
3785 "mode": "required",
3786 "tools": [
3787 {"type": "function", "function": {"name": "read_file"}}
3788 ]
3789 }
3790 })
3791 );
3792
3793 let missing = OpenAIReasoningConfig::new()
3794 .with_tool_choice(OpenAIToolChoice::Function("missing".to_owned()));
3795 assert!(
3796 OpenAIProvider::resolve_chat_tool_choice(None, Some(&missing), Some(&tools)).is_err()
3797 );
3798 assert!(OpenAIProvider::resolve_chat_tool_choice(None, Some(&required), None).is_err());
3799 Ok(())
3800 }
3801
3802 #[test]
3803 fn generic_tool_choice_overrides_provider_exact_choice() -> anyhow::Result<()> {
3804 let tools = vec![function_tool("read_file")];
3805 let request_choice = ToolChoice::Tool("read_file".to_owned());
3806 let provider_choice =
3807 OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::AllowedTools {
3808 mode: super::super::openai_reasoning::OpenAIAllowedToolsMode::Required,
3809 tools: vec!["missing".to_owned()],
3810 });
3811 let request = ChatRequest::new(String::new(), vec![])
3812 .with_tools(tools.clone())
3813 .with_tool_choice(request_choice.clone());
3814
3815 assert!(!should_use_responses_api(
3816 "https://gateway.example/v1",
3817 MODEL_GPT54,
3818 &request,
3819 Some(&provider_choice),
3820 ));
3821 let choice = OpenAIProvider::resolve_chat_tool_choice(
3822 Some(&request_choice),
3823 Some(&provider_choice),
3824 Some(&tools),
3825 )?
3826 .context("generic tool choice was omitted")?;
3827 assert_eq!(
3828 serde_json::to_value(choice)?,
3829 serde_json::json!({"type": "function", "function": {"name": "read_file"}})
3830 );
3831 Ok(())
3832 }
3833
3834 #[test]
3835 fn generic_cache_control_overrides_exact_defaults_and_rejects_unmappable_ttl()
3836 -> anyhow::Result<()> {
3837 use agent_sdk_foundation::llm::{CacheConfig, CacheTtl};
3838
3839 let provider = OpenAIProvider::gpt56("test-key".to_owned());
3840 let exact = OpenAIReasoningConfig::new()
3841 .with_prompt_cache_mode(OpenAIPromptCacheMode::Explicit)
3842 .with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes);
3843
3844 let inherited_plan = provider.resolve_chat_prompt_cache_options(
3845 &ChatRequest::new(String::new(), vec![]),
3846 Some(&exact),
3847 )?;
3848 assert_eq!(inherited_plan.explicit_breakpoints, 0);
3849 let inherited = inherited_plan
3850 .options
3851 .context("exact prompt-cache options were omitted")?;
3852 assert_eq!(
3853 serde_json::to_value(inherited)?,
3854 serde_json::json!({"mode": "explicit", "ttl": "30m"})
3855 );
3856
3857 let enabled = ChatRequest::new(String::new(), vec![]).with_cache(CacheConfig::enabled());
3858 let enabled_plan = provider.resolve_chat_prompt_cache_options(&enabled, Some(&exact))?;
3859 assert_eq!(enabled_plan.explicit_breakpoints, 0);
3860 let enabled = enabled_plan
3861 .options
3862 .context("generic enabled cache options were omitted")?;
3863 assert_eq!(
3864 serde_json::to_value(enabled)?,
3865 serde_json::json!({"mode": "explicit", "ttl": "30m"})
3866 );
3867
3868 let disabled = ChatRequest::new(String::new(), vec![]).with_cache(CacheConfig::disabled());
3869 let disabled_plan = provider.resolve_chat_prompt_cache_options(&disabled, Some(&exact))?;
3870 assert_eq!(disabled_plan.explicit_breakpoints, 0);
3871 let disabled = disabled_plan
3872 .options
3873 .context("generic disabled cache options were omitted")?;
3874 assert_eq!(
3875 serde_json::to_value(disabled)?,
3876 serde_json::json!({"mode": "explicit"})
3877 );
3878
3879 let zero_breakpoints = ChatRequest::new(String::new(), vec![])
3880 .with_cache(CacheConfig::enabled().with_max_breakpoints(0));
3881 let zero_breakpoints_plan =
3882 provider.resolve_chat_prompt_cache_options(&zero_breakpoints, Some(&exact))?;
3883 assert_eq!(zero_breakpoints_plan.explicit_breakpoints, 0);
3884 let zero_breakpoints = zero_breakpoints_plan
3885 .options
3886 .context("zero-breakpoint cache options were omitted")?;
3887 assert_eq!(
3888 serde_json::to_value(zero_breakpoints)?,
3889 serde_json::json!({"mode": "explicit", "ttl": "30m"})
3890 );
3891
3892 let two_breakpoints = ChatRequest::new(String::new(), vec![])
3893 .with_cache(CacheConfig::enabled().with_max_breakpoints(2));
3894 let two_breakpoints =
3895 provider.resolve_chat_prompt_cache_options(&two_breakpoints, Some(&exact))?;
3896 assert_eq!(two_breakpoints.explicit_breakpoints, 2);
3897
3898 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
3899 for cache in [
3900 CacheConfig::enabled().with_ttl(ttl),
3901 CacheConfig::disabled().with_ttl(ttl),
3902 ] {
3903 let request = ChatRequest::new(String::new(), vec![]).with_cache(cache);
3904 let error = provider
3905 .resolve_chat_prompt_cache_options(&request, Some(&exact))
3906 .err()
3907 .context("shared cache TTL was silently accepted")?;
3908 assert!(error.to_string().contains("supports only a 30m TTL"));
3909 }
3910 }
3911 Ok(())
3912 }
3913
3914 #[test]
3915 fn chat_cache_breakpoints_prioritize_system_then_conversation_tail() -> anyhow::Result<()> {
3916 let request = ChatRequest::new(
3917 "system",
3918 vec![
3919 agent_sdk_foundation::llm::Message::user("one"),
3920 agent_sdk_foundation::llm::Message::assistant("two"),
3921 agent_sdk_foundation::llm::Message::user("three"),
3922 agent_sdk_foundation::llm::Message::assistant("four"),
3923 agent_sdk_foundation::llm::Message::user("five"),
3924 ],
3925 );
3926 let json = serde_json::to_value(build_api_messages_with_cache(&request, 9))?;
3927 let messages = json.as_array().context("messages must be an array")?;
3928 let marked = messages
3929 .iter()
3930 .filter(|message| message["content"].is_array())
3931 .count();
3932 assert_eq!(marked, 4);
3933 assert_eq!(messages[0]["content"][0]["text"], "system");
3934 assert_eq!(
3935 messages[0]["content"][0]["prompt_cache_breakpoint"]["mode"],
3936 "explicit"
3937 );
3938 assert_eq!(messages[1]["content"], "one");
3939 assert_eq!(messages[2]["content"], "two");
3940 assert_eq!(messages[5]["content"][0]["text"], "five");
3941 assert_eq!(
3942 messages[5]["content"][0]["prompt_cache_breakpoint"]["mode"],
3943 "explicit"
3944 );
3945
3946 let one_breakpoint = serde_json::to_value(build_api_messages_with_cache(&request, 1))?;
3947 let messages = one_breakpoint
3948 .as_array()
3949 .context("messages must be an array")?;
3950 assert!(messages[0]["content"].is_array());
3951 assert!(messages[5]["content"].is_string());
3952 Ok(())
3953 }
3954
3955 #[test]
3956 fn chat_strict_response_format_rejects_free_form_objects() -> anyhow::Result<()> {
3957 let format = agent_sdk_foundation::llm::ResponseFormat::new(
3958 "freeform",
3959 serde_json::json!({"type": "object"}),
3960 );
3961 assert!(
3962 OpenAIProvider::gpt54("test-key".to_owned())
3963 .validate_chat_response_format(Some(&format))
3964 .is_err()
3965 );
3966 assert!(
3967 OpenAIProvider::with_base_url("test-key", MODEL_GPT54, "https://gateway.example/v1",)
3968 .validate_chat_response_format(Some(&format))
3969 .is_ok()
3970 );
3971
3972 let structured = agent_sdk_foundation::llm::ResponseFormat::new(
3973 "structured",
3974 serde_json::json!({
3975 "type": "object",
3976 "properties": {"optional_name": {"type": "string"}}
3977 }),
3978 );
3979 let compatible = ApiResponseFormat::from_response_format(&structured, false);
3980 let compatible_json = serde_json::to_value(compatible)?;
3981 assert!(
3982 compatible_json["json_schema"]["schema"]
3983 .get("required")
3984 .is_none()
3985 );
3986
3987 let official = ApiResponseFormat::from_response_format(&structured, true);
3988 let official_json = serde_json::to_value(official)?;
3989 assert_eq!(
3990 official_json["json_schema"]["schema"]["required"],
3991 serde_json::json!(["optional_name"])
3992 );
3993 Ok(())
3994 }
3995
3996 #[test]
3997 fn forced_or_custom_chat_rejects_unserialized_attachments() {
3998 let request = ChatRequest::new(
3999 String::new(),
4000 vec![agent_sdk_foundation::llm::Message::user_with_content(vec![
4001 ContentBlock::Image {
4002 source: agent_sdk_foundation::llm::ContentSource::new("image/png", "aGVsbG8="),
4003 },
4004 ])],
4005 );
4006 let forced_chat =
4007 OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions);
4008
4009 assert!(!should_use_responses_api(
4010 DEFAULT_BASE_URL,
4011 MODEL_GPT54,
4012 &request,
4013 Some(&forced_chat),
4014 ));
4015 assert!(
4016 OpenAIProvider::gpt54("test-key".to_owned())
4017 .with_reasoning(forced_chat)
4018 .validate_chat_attachments(&request)
4019 .is_err()
4020 );
4021 assert!(
4022 OpenAIProvider::with_base_url("test-key", MODEL_GPT54, "https://gateway.example/v1",)
4023 .validate_chat_attachments(&request)
4024 .is_err()
4025 );
4026 assert!(should_use_responses_api(
4027 DEFAULT_BASE_URL,
4028 MODEL_GPT54,
4029 &request,
4030 None,
4031 ));
4032 }
4033
4034 #[test]
4035 fn legacy_budget_and_effort_mapping_remains_compatible() {
4036 assert_eq!(
4037 legacy_reasoning_effort(&ThinkingConfig::new(40_000)),
4038 Some(OpenAIReasoningEffort::XHigh)
4039 );
4040 assert_eq!(
4041 legacy_reasoning_effort(&ThinkingConfig::adaptive_with_effort(
4042 agent_sdk_foundation::llm::Effort::High,
4043 )),
4044 Some(OpenAIReasoningEffort::High)
4045 );
4046 assert_eq!(legacy_reasoning_effort(&ThinkingConfig::adaptive()), None);
4047 }
4048
4049 #[test]
4050 fn effective_max_tokens_uses_model_default_unless_explicit() {
4051 let provider = OpenAIProvider::gpt56("test-key".to_string());
4052 let implicit = ChatRequest::new(String::new(), vec![]);
4053 assert_eq!(provider.effective_max_tokens(&implicit), 128_000);
4054
4055 let explicit = implicit.with_max_tokens(8_192);
4056 assert_eq!(provider.effective_max_tokens(&explicit), 8_192);
4057 }
4058
4059 #[test]
4060 fn test_openai_rejects_adaptive_thinking() {
4061 let provider = OpenAIProvider::gpt54("test-key".to_string());
4062 let error = provider
4063 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
4064 .unwrap_err();
4065 assert!(
4066 error
4067 .to_string()
4068 .contains("adaptive thinking is not supported")
4069 );
4070 }
4071
4072 #[test]
4073 fn test_openai_non_reasoning_models_reject_thinking() {
4074 let provider = OpenAIProvider::gpt4o("test-key".to_string());
4075 let error = provider
4076 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
4077 .unwrap_err();
4078 assert!(error.to_string().contains("thinking is not supported"));
4079 }
4080
4081 #[test]
4082 fn test_request_serialization_openai_uses_max_completion_tokens_only() {
4083 let messages = vec![ApiMessage {
4084 role: ApiRole::User,
4085 content: Some("Hello".to_string()),
4086 reasoning_content: None,
4087 tool_calls: None,
4088 tool_call_id: None,
4089 prompt_cache_breakpoint: false,
4090 }];
4091
4092 let request = ApiChatRequest {
4093 model: "gpt-4o",
4094 messages: &messages,
4095 max_completion_tokens: Some(1024),
4096 max_tokens: None,
4097 tools: None,
4098 tool_choice: None,
4099 reasoning: None,
4100 response_format: None,
4101 verbosity: None,
4102 prompt_cache_options: None,
4103 store: Some(false),
4104 parallel_tool_calls: None,
4105 safety_identifier: None,
4106 prompt_cache_key: None,
4107 };
4108
4109 let json = serde_json::to_string(&request).unwrap();
4110 assert!(json.contains("\"max_completion_tokens\":1024"));
4111 assert!(!json.contains("\"max_tokens\""));
4112 assert!(json.contains("\"store\":false"));
4113 }
4114
4115 #[test]
4116 fn custom_chat_omits_first_party_storage_and_cache_defaults() {
4117 let explicit_store = OpenAIReasoningConfig::new().with_store(true);
4118
4119 assert_eq!(chat_store(DEFAULT_BASE_URL, None), Some(false));
4120 assert_eq!(
4121 chat_store("https://gateway.example/v1", Some(&explicit_store)),
4122 Some(true)
4123 );
4124 assert_eq!(chat_store("https://gateway.example/v1", None), None);
4125 assert_eq!(
4126 chat_prompt_cache_key(DEFAULT_BASE_URL, Some("thread-42")),
4127 Some("thread-42")
4128 );
4129 assert_eq!(
4130 chat_prompt_cache_key("https://gateway.example/v1", Some("thread-42")),
4131 None
4132 );
4133 }
4134
4135 #[test]
4136 fn official_openai_url_detection_uses_the_exact_parsed_host() {
4137 assert!(is_official_openai_base_url(DEFAULT_BASE_URL));
4138 assert!(is_official_openai_base_url(
4139 "https://api.openai.com/custom-prefix/v1"
4140 ));
4141 assert!(!is_official_openai_base_url(
4142 "https://api.openai.com.attacker.example/v1"
4143 ));
4144 assert!(!is_official_openai_base_url(
4145 "https://gateway.example/v1?upstream=api.openai.com"
4146 ));
4147 assert!(!is_official_openai_base_url("not a URL api.openai.com"));
4148 }
4149
4150 #[test]
4151 fn test_request_serializes_openai_application_controls() -> anyhow::Result<()> {
4152 let messages = vec![ApiMessage {
4153 role: ApiRole::User,
4154 content: Some("Hello".to_owned()),
4155 reasoning_content: None,
4156 tool_calls: None,
4157 tool_call_id: None,
4158 prompt_cache_breakpoint: false,
4159 }];
4160 let request = ApiChatRequest {
4161 model: MODEL_GPT56,
4162 messages: &messages,
4163 max_completion_tokens: Some(1024),
4164 max_tokens: None,
4165 tools: None,
4166 tool_choice: None,
4167 reasoning: None,
4168 response_format: None,
4169 verbosity: None,
4170 prompt_cache_options: None,
4171 store: Some(true),
4172 parallel_tool_calls: Some(false),
4173 safety_identifier: Some("safety-user-42"),
4174 prompt_cache_key: Some("thread-42"),
4175 };
4176
4177 let json = serde_json::to_value(request)?;
4178 assert_eq!(json["store"], true);
4179 assert_eq!(json["parallel_tool_calls"], false);
4180 assert_eq!(json["safety_identifier"], "safety-user-42");
4181 assert_eq!(json["prompt_cache_key"], "thread-42");
4182 Ok(())
4183 }
4184
4185 #[test]
4186 fn test_request_serialization_with_max_tokens_alias() {
4187 let messages = vec![ApiMessage {
4188 role: ApiRole::User,
4189 content: Some("Hello".to_string()),
4190 reasoning_content: None,
4191 tool_calls: None,
4192 tool_call_id: None,
4193 prompt_cache_breakpoint: false,
4194 }];
4195
4196 let request = ApiChatRequest {
4197 model: "glm-5",
4198 messages: &messages,
4199 max_completion_tokens: Some(1024),
4200 max_tokens: Some(1024),
4201 tools: None,
4202 tool_choice: None,
4203 reasoning: None,
4204 response_format: None,
4205 verbosity: None,
4206 prompt_cache_options: None,
4207 store: None,
4208 parallel_tool_calls: None,
4209 safety_identifier: None,
4210 prompt_cache_key: None,
4211 };
4212
4213 let json = serde_json::to_string(&request).unwrap();
4214 assert!(json.contains("\"max_completion_tokens\":1024"));
4215 assert!(json.contains("\"max_tokens\":1024"));
4216 assert!(!json.contains("\"store\""));
4217 }
4218
4219 #[test]
4220 fn test_streaming_request_serialization_openai_default() {
4221 let messages = vec![ApiMessage {
4222 role: ApiRole::User,
4223 content: Some("Hello".to_string()),
4224 reasoning_content: None,
4225 tool_calls: None,
4226 tool_call_id: None,
4227 prompt_cache_breakpoint: false,
4228 }];
4229
4230 let request = ApiChatRequestStreaming {
4231 model: "gpt-4o",
4232 messages: &messages,
4233 max_completion_tokens: Some(1024),
4234 max_tokens: None,
4235 tools: None,
4236 tool_choice: None,
4237 reasoning: None,
4238 response_format: None,
4239 verbosity: None,
4240 prompt_cache_options: None,
4241 store: Some(false),
4242 parallel_tool_calls: None,
4243 safety_identifier: None,
4244 prompt_cache_key: None,
4245 stream_options: Some(ApiStreamOptions {
4246 include_usage: true,
4247 }),
4248 usage: None,
4249 stream: true,
4250 };
4251
4252 let json = serde_json::to_string(&request).unwrap();
4253 assert!(json.contains("\"stream\":true"));
4254 assert!(json.contains("\"model\":\"gpt-4o\""));
4255 assert!(json.contains("\"max_completion_tokens\":1024"));
4256 assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
4257 assert!(!json.contains("\"max_tokens\""));
4258 }
4259
4260 #[test]
4261 fn stream_usage_is_requested_for_every_endpoint() {
4262 assert!(use_stream_usage_options("https://api.openai.com/v1"));
4266 assert!(use_stream_usage_options("https://openrouter.ai/api/v1"));
4267 assert!(use_stream_usage_options("https://host.baseten.co/v1"));
4268 assert!(use_stream_usage_options("http://localhost:1234/v1"));
4269 }
4270
4271 #[test]
4272 fn openrouter_usage_flag_only_for_openrouter() {
4273 assert!(use_openrouter_usage_options("https://openrouter.ai/api/v1"));
4274 assert!(!use_openrouter_usage_options("https://api.openai.com/v1"));
4275 }
4276
4277 #[test]
4278 fn streaming_request_serializes_openrouter_usage_flag() -> anyhow::Result<()> {
4279 let messages = vec![ApiMessage {
4280 role: ApiRole::User,
4281 content: Some("hi".to_string()),
4282 reasoning_content: None,
4283 tool_calls: None,
4284 tool_call_id: None,
4285 prompt_cache_breakpoint: false,
4286 }];
4287 let request = ApiChatRequestStreaming {
4288 model: "anthropic/claude-3.5",
4289 messages: &messages,
4290 max_completion_tokens: Some(16),
4291 max_tokens: None,
4292 tools: None,
4293 tool_choice: None,
4294 reasoning: None,
4295 response_format: None,
4296 verbosity: None,
4297 prompt_cache_options: None,
4298 store: None,
4299 parallel_tool_calls: None,
4300 safety_identifier: None,
4301 prompt_cache_key: None,
4302 stream_options: Some(ApiStreamOptions {
4303 include_usage: true,
4304 }),
4305 usage: Some(ApiOpenRouterUsageOptions { include: true }),
4306 stream: true,
4307 };
4308 let json = serde_json::to_string(&request)?;
4309 assert!(json.contains("\"usage\":{\"include\":true}"));
4310 assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
4311 Ok(())
4312 }
4313
4314 #[test]
4315 fn usage_only_chunk_without_choices_deserializes() -> anyhow::Result<()> {
4316 let no_choices: SseChunk = serde_json::from_str("{}")?;
4320 assert!(no_choices.choices.is_empty());
4321
4322 let usage_only: SseChunk =
4323 serde_json::from_str(r#"{"usage":{"prompt_tokens":10,"completion_tokens":5}}"#)?;
4324 assert!(usage_only.choices.is_empty());
4325 assert!(usage_only.usage.is_some());
4326 Ok(())
4327 }
4328
4329 #[test]
4330 fn test_streaming_request_serialization_with_max_tokens_alias() {
4331 let messages = vec![ApiMessage {
4332 role: ApiRole::User,
4333 content: Some("Hello".to_string()),
4334 reasoning_content: None,
4335 tool_calls: None,
4336 tool_call_id: None,
4337 prompt_cache_breakpoint: false,
4338 }];
4339
4340 let request = ApiChatRequestStreaming {
4341 model: "kimi-k2-thinking",
4342 messages: &messages,
4343 max_completion_tokens: Some(1024),
4344 max_tokens: Some(1024),
4345 tools: None,
4346 tool_choice: None,
4347 reasoning: None,
4348 response_format: None,
4349 verbosity: None,
4350 prompt_cache_options: None,
4351 store: None,
4352 parallel_tool_calls: None,
4353 safety_identifier: None,
4354 prompt_cache_key: None,
4355 stream_options: None,
4356 usage: None,
4357 stream: true,
4358 };
4359
4360 let json = serde_json::to_string(&request).unwrap();
4361 assert!(json.contains("\"max_completion_tokens\":1024"));
4362 assert!(json.contains("\"max_tokens\":1024"));
4363 assert!(!json.contains("\"stream_options\""));
4364 assert!(!json.contains("\"store\""));
4365 }
4366
4367 #[test]
4368 fn test_request_serialization_uses_top_level_reasoning_effort() -> anyhow::Result<()> {
4369 let messages = vec![ApiMessage {
4370 role: ApiRole::User,
4371 content: Some("Hello".to_string()),
4372 reasoning_content: None,
4373 tool_calls: None,
4374 tool_call_id: None,
4375 prompt_cache_breakpoint: false,
4376 }];
4377
4378 let request = ApiChatRequest {
4379 model: MODEL_GPT54,
4380 messages: &messages,
4381 max_completion_tokens: Some(1024),
4382 max_tokens: None,
4383 tools: None,
4384 tool_choice: None,
4385 reasoning: build_chat_api_reasoning(
4386 Some(&OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::High)),
4387 true,
4388 ),
4389 response_format: None,
4390 verbosity: None,
4391 prompt_cache_options: None,
4392 store: Some(false),
4393 parallel_tool_calls: None,
4394 safety_identifier: None,
4395 prompt_cache_key: None,
4396 };
4397
4398 let json = serde_json::to_value(&request)?;
4399 assert_eq!(json["reasoning_effort"], "high");
4400 assert!(json.get("reasoning").is_none());
4401 Ok(())
4402 }
4403
4404 #[test]
4405 fn test_compatible_request_serialization_preserves_nested_reasoning() -> anyhow::Result<()> {
4406 let messages = vec![ApiMessage {
4407 role: ApiRole::User,
4408 content: Some("Hello".to_string()),
4409 reasoning_content: None,
4410 tool_calls: None,
4411 tool_call_id: None,
4412 prompt_cache_breakpoint: false,
4413 }];
4414 let reasoning = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::High);
4415
4416 let request = ApiChatRequest {
4417 model: "compatible-model",
4418 messages: &messages,
4419 max_completion_tokens: Some(1024),
4420 max_tokens: None,
4421 tools: None,
4422 tool_choice: None,
4423 reasoning: build_chat_api_reasoning(Some(&reasoning), false),
4424 response_format: None,
4425 verbosity: None,
4426 prompt_cache_options: None,
4427 store: None,
4428 parallel_tool_calls: None,
4429 safety_identifier: None,
4430 prompt_cache_key: None,
4431 };
4432
4433 let json = serde_json::to_value(&request)?;
4434 assert_eq!(json["reasoning"]["effort"], "high");
4435 assert!(json.get("reasoning_effort").is_none());
4436 Ok(())
4437 }
4438
4439 #[test]
4440 fn test_response_format_serializes_as_json_schema() {
4441 let messages = vec![ApiMessage {
4442 role: ApiRole::User,
4443 content: Some("Hello".to_string()),
4444 reasoning_content: None,
4445 tool_calls: None,
4446 tool_call_id: None,
4447 prompt_cache_breakpoint: false,
4448 }];
4449
4450 let response_format = Some(ApiResponseFormat::from_response_format(
4451 &agent_sdk_foundation::llm::ResponseFormat::new(
4452 "person",
4453 serde_json::json!({"type": "object"}),
4454 ),
4455 true,
4456 ));
4457
4458 let request = ApiChatRequest {
4459 model: "gpt-4o",
4460 messages: &messages,
4461 max_completion_tokens: Some(1024),
4462 max_tokens: None,
4463 tools: None,
4464 tool_choice: None,
4465 reasoning: None,
4466 response_format,
4467 verbosity: None,
4468 prompt_cache_options: None,
4469 store: Some(false),
4470 parallel_tool_calls: None,
4471 safety_identifier: None,
4472 prompt_cache_key: None,
4473 };
4474
4475 let json = serde_json::to_value(&request).unwrap();
4476 assert_eq!(json["response_format"]["type"], "json_schema");
4477 assert_eq!(json["response_format"]["json_schema"]["name"], "person");
4478 assert_eq!(json["response_format"]["json_schema"]["strict"], true);
4479 assert_eq!(
4480 json["response_format"]["json_schema"]["schema"]["type"],
4481 "object"
4482 );
4483 }
4484
4485 #[test]
4486 fn test_step_completion_stream_emits_trailing_usage_after_finish_reason() {
4487 let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
4493 let mut usage: Option<Usage> = None;
4494 let mut stop_reason: Option<StopReason> = None;
4495
4496 let o1 = step_completion_stream(
4498 r#"{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}"#,
4499 &mut tool_calls,
4500 &mut usage,
4501 &mut stop_reason,
4502 );
4503 assert!(o1.terminal.is_none());
4504 assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
4505
4506 let o2 = step_completion_stream(
4508 r#"{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
4509 &mut tool_calls,
4510 &mut usage,
4511 &mut stop_reason,
4512 );
4513 assert!(o2.terminal.is_none());
4514
4515 let o3 = step_completion_stream("[DONE]", &mut tool_calls, &mut usage, &mut stop_reason);
4517 let terminal = o3.terminal.expect("[DONE] finalizes the stream");
4518 assert!(terminal.iter().any(|d| matches!(
4519 d,
4520 StreamDelta::Usage(Usage {
4521 input_tokens: 10,
4522 output_tokens: 5,
4523 ..
4524 })
4525 )));
4526 assert!(terminal.iter().any(|d| matches!(
4527 d,
4528 StreamDelta::Done {
4529 stop_reason: Some(StopReason::EndTurn)
4530 }
4531 )));
4532 }
4533
4534 #[test]
4535 fn malformed_chat_sse_is_an_immediate_server_error() -> anyhow::Result<()> {
4536 let mut tool_calls = HashMap::new();
4537 let mut usage = None;
4538 let mut stop_reason = None;
4539 let outcome = step_completion_stream(
4540 "{not valid json",
4541 &mut tool_calls,
4542 &mut usage,
4543 &mut stop_reason,
4544 );
4545 let SseLineOutcome {
4546 immediate,
4547 terminal,
4548 } = outcome;
4549 let terminal = terminal.context("malformed SSE event did not terminate the stream")?;
4550
4551 assert!(immediate.is_empty());
4552 assert!(matches!(
4553 terminal.as_slice(),
4554 [StreamDelta::Error {
4555 kind: StreamErrorKind::ServerError,
4556 ..
4557 }]
4558 ));
4559 Ok(())
4560 }
4561
4562 #[test]
4563 fn non_tool_stream_terminal_suppresses_accumulated_tool_calls() {
4564 let tool_calls = HashMap::from([(
4565 0,
4566 ToolCallAccumulator {
4567 id: "call_partial".to_owned(),
4568 name: "delete_record".to_owned(),
4569 arguments: "{\"id\":".to_owned(),
4570 },
4571 )]);
4572
4573 let truncated = build_stream_end_deltas(&tool_calls, None, StopReason::MaxTokens);
4574 assert!(!truncated.iter().any(|delta| matches!(
4575 delta,
4576 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
4577 )));
4578
4579 let tool_terminal = build_stream_end_deltas(&tool_calls, None, StopReason::ToolUse);
4580 assert!(
4581 tool_terminal
4582 .iter()
4583 .any(|delta| matches!(delta, StreamDelta::ToolUseStart { .. }))
4584 );
4585 }
4586
4587 #[test]
4588 fn test_response_format_omitted_when_absent() {
4589 let messages = vec![ApiMessage {
4590 role: ApiRole::User,
4591 content: Some("Hello".to_string()),
4592 reasoning_content: None,
4593 tool_calls: None,
4594 tool_call_id: None,
4595 prompt_cache_breakpoint: false,
4596 }];
4597
4598 let request = ApiChatRequest {
4599 model: "gpt-4o",
4600 messages: &messages,
4601 max_completion_tokens: Some(1024),
4602 max_tokens: None,
4603 tools: None,
4604 tool_choice: None,
4605 reasoning: None,
4606 response_format: None,
4607 verbosity: None,
4608 prompt_cache_options: None,
4609 store: Some(false),
4610 parallel_tool_calls: None,
4611 safety_identifier: None,
4612 prompt_cache_key: None,
4613 };
4614
4615 let json = serde_json::to_string(&request).unwrap();
4616 assert!(!json.contains("response_format"));
4617 }
4618
4619 #[tokio::test]
4620 async fn stream_eof_before_done_is_an_error() -> anyhow::Result<()> {
4621 use wiremock::matchers::{method, path};
4622 use wiremock::{Mock, MockServer, ResponseTemplate};
4623
4624 let server = MockServer::start().await;
4625 Mock::given(method("POST"))
4626 .and(path("/chat/completions"))
4627 .respond_with(
4628 ResponseTemplate::new(200)
4629 .insert_header("content-type", "text/event-stream")
4630 .set_body_string(
4631 "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"stop\"}]}\n\n",
4632 ),
4633 )
4634 .mount(&server)
4635 .await;
4636
4637 let provider = OpenAIProvider::with_base_url("test-key", MODEL_GPT4O, server.uri());
4638 let request = ChatRequest::new(
4639 String::new(),
4640 vec![agent_sdk_foundation::llm::Message::user("hello")],
4641 );
4642 let items = provider.chat_stream(request).collect::<Vec<_>>().await;
4643
4644 assert!(items.iter().any(|item| matches!(
4645 item,
4646 Ok(StreamDelta::TextDelta { delta, .. }) if delta == "partial"
4647 )));
4648 assert!(
4649 !items
4650 .iter()
4651 .any(|item| matches!(item, Ok(StreamDelta::Done { .. })))
4652 );
4653 let last = items.last().context("stream emitted no events")?;
4654 let Err(error) = last else {
4655 anyhow::bail!("truncated stream did not end with an error");
4656 };
4657 assert!(error.to_string().contains("before [DONE] sentinel"));
4658 Ok(())
4659 }
4660}