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