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