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 route(&self) -> &str {
1103 serving_route(&self.base_url)
1104 }
1105
1106 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1107 self.thinking.as_ref()
1108 }
1109}
1110
1111fn serving_route(base_url: &str) -> &'static str {
1119 if base_url.contains("openrouter.ai") {
1120 "openrouter"
1121 } else if base_url.contains("baseten") {
1122 "baseten"
1123 } else if base_url.contains("fireworks") {
1124 "fireworks"
1125 } else {
1126 "openai"
1127 }
1128}
1129
1130fn parse_models_list(body: &str) -> Result<Vec<crate::provider::ModelInfo>> {
1136 #[derive(Deserialize)]
1137 struct ListResponse {
1138 #[serde(default)]
1139 data: Vec<ModelRow>,
1140 }
1141 #[derive(Deserialize)]
1142 struct ModelRow {
1143 id: String,
1144 }
1145 let parsed: ListResponse = serde_json::from_str(body)
1146 .map_err(|e| anyhow::anyhow!("failed to parse OpenAI models list: {e}"))?;
1147 Ok(parsed
1148 .data
1149 .into_iter()
1150 .map(|row| crate::provider::ModelInfo {
1151 id: row.id,
1152 display_name: None,
1153 context_window: None,
1154 max_output_tokens: None,
1155 })
1156 .collect())
1157}
1158
1159fn apply_tool_call_update(
1161 tool_calls: &mut std::collections::HashMap<usize, ToolCallAccumulator>,
1162 index: usize,
1163 id: Option<String>,
1164 name: Option<String>,
1165 arguments: Option<String>,
1166) {
1167 let entry = tool_calls
1168 .entry(index)
1169 .or_insert_with(|| ToolCallAccumulator {
1170 id: String::new(),
1171 name: String::new(),
1172 arguments: String::new(),
1173 });
1174 if let Some(id) = id {
1175 entry.id = id;
1176 }
1177 if let Some(name) = name {
1178 entry.name = name;
1179 }
1180 if let Some(args) = arguments {
1181 entry.arguments.push_str(&args);
1182 }
1183}
1184
1185struct SseLineOutcome {
1188 immediate: Vec<StreamDelta>,
1190 terminal: Option<Vec<StreamDelta>>,
1193}
1194
1195fn step_completion_stream(
1203 data: &str,
1204 tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
1205 usage: &mut Option<Usage>,
1206 stop_reason: &mut Option<StopReason>,
1207) -> SseLineOutcome {
1208 let mut immediate = Vec::new();
1209 for result in process_sse_data(data) {
1210 match result {
1211 SseProcessResult::TextDelta(c) => {
1212 immediate.push(StreamDelta::TextDelta {
1213 delta: c,
1214 block_index: 0,
1215 });
1216 }
1217 SseProcessResult::RefusalDelta(c) => {
1218 immediate.push(StreamDelta::TextDelta {
1219 delta: c,
1220 block_index: 0,
1221 });
1222 *stop_reason = Some(StopReason::Refusal);
1223 }
1224 SseProcessResult::ThinkingDelta(c) => {
1225 immediate.push(StreamDelta::ThinkingDelta {
1226 delta: c,
1227 block_index: 0,
1228 });
1229 }
1230 SseProcessResult::ToolCallUpdate {
1231 index,
1232 id,
1233 name,
1234 arguments,
1235 } => apply_tool_call_update(tool_calls, index, id, name, arguments),
1236 SseProcessResult::Usage(u) => *usage = Some(u),
1237 SseProcessResult::Done(sr) => {
1238 if !matches!(stop_reason, Some(StopReason::Refusal)) {
1239 *stop_reason = Some(sr);
1240 }
1241 }
1242 SseProcessResult::Malformed(message) => {
1243 return SseLineOutcome {
1244 immediate,
1245 terminal: Some(terminal_error_deltas(
1246 usage.take(),
1247 message,
1248 StreamErrorKind::ServerError,
1249 )),
1250 };
1251 }
1252 SseProcessResult::Error { message, kind } => {
1253 return SseLineOutcome {
1254 immediate,
1255 terminal: Some(terminal_error_deltas(usage.take(), message, kind)),
1256 };
1257 }
1258 SseProcessResult::Sentinel => {
1259 let sr = stop_reason.unwrap_or_else(|| fallback_stream_stop_reason(tool_calls));
1260 let terminal = build_stream_end_deltas(tool_calls, usage.take(), sr);
1261 return SseLineOutcome {
1262 immediate,
1263 terminal: Some(terminal),
1264 };
1265 }
1266 }
1267 }
1268 SseLineOutcome {
1269 immediate,
1270 terminal: None,
1271 }
1272}
1273
1274fn build_stream_end_deltas(
1276 tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
1277 usage: Option<Usage>,
1278 stop_reason: StopReason,
1279) -> Vec<StreamDelta> {
1280 let mut deltas = Vec::new();
1281
1282 if matches!(stop_reason, StopReason::ToolUse) {
1283 for (idx, tool) in tool_calls {
1287 let block_index = idx.saturating_add(1);
1288 deltas.push(StreamDelta::ToolUseStart {
1289 id: tool.id.clone(),
1290 name: tool.name.clone(),
1291 block_index,
1292 thought_signature: None,
1293 });
1294 deltas.push(StreamDelta::ToolInputDelta {
1295 id: tool.id.clone(),
1296 delta: tool.arguments.clone(),
1297 block_index,
1298 });
1299 }
1300 }
1301
1302 if let Some(u) = usage {
1304 deltas.push(StreamDelta::Usage(u));
1305 }
1306
1307 deltas.push(StreamDelta::Done {
1309 stop_reason: Some(stop_reason),
1310 });
1311
1312 deltas
1313}
1314
1315enum SseProcessResult {
1317 TextDelta(String),
1319 RefusalDelta(String),
1321 ThinkingDelta(String),
1325 ToolCallUpdate {
1327 index: usize,
1328 id: Option<String>,
1329 name: Option<String>,
1330 arguments: Option<String>,
1331 },
1332 Usage(Usage),
1334 Done(StopReason),
1336 Malformed(String),
1338 Error {
1340 message: String,
1341 kind: StreamErrorKind,
1342 },
1343 Sentinel,
1345}
1346
1347fn terminal_error_deltas(
1354 usage: Option<Usage>,
1355 message: String,
1356 kind: StreamErrorKind,
1357) -> Vec<StreamDelta> {
1358 let mut deltas = Vec::new();
1359 if let Some(usage) = usage {
1360 deltas.push(StreamDelta::Usage(usage));
1361 }
1362 deltas.push(StreamDelta::Error { message, kind });
1363 deltas
1364}
1365
1366fn completion_error_kind(code: Option<&serde_json::Value>, message: &str) -> StreamErrorKind {
1374 let http_code = code.and_then(serde_json::Value::as_u64);
1375 let symbolic = code.and_then(serde_json::Value::as_str);
1376
1377 if http_code == Some(429)
1378 || matches!(symbolic, Some("rate_limit_exceeded" | "rate_limit_error"))
1379 {
1380 return StreamErrorKind::RateLimited(crate::retry_hints::openai_retry_delay(message));
1381 }
1382 if http_code.is_some_and(|code| (400..500).contains(&code)) {
1385 return StreamErrorKind::InvalidRequest;
1386 }
1387 StreamErrorKind::ServerError
1388}
1389
1390fn process_sse_data(data: &str) -> Vec<SseProcessResult> {
1392 if data == "[DONE]" {
1393 return vec![SseProcessResult::Sentinel];
1394 }
1395
1396 let chunk = match serde_json::from_str::<SseChunk>(data) {
1397 Ok(chunk) => chunk,
1398 Err(error) => {
1399 return vec![SseProcessResult::Malformed(format!(
1400 "invalid OpenAI Chat Completions stream event: {error}"
1401 ))];
1402 }
1403 };
1404
1405 let mut results = Vec::new();
1406
1407 if let Some(u) = chunk.usage {
1411 results.push(SseProcessResult::Usage(Usage {
1412 input_tokens: u.prompt_tokens,
1413 output_tokens: u.completion_tokens,
1414 cached_input_tokens: u
1415 .prompt_tokens_details
1416 .as_ref()
1417 .map_or(0, |details| details.cached_tokens),
1418 cache_creation_input_tokens: u
1419 .prompt_tokens_details
1420 .as_ref()
1421 .map_or(0, |details| details.cache_write_tokens),
1422 }));
1423 }
1424
1425 if let Some(error) = chunk.error {
1429 let message = error
1430 .message
1431 .unwrap_or_else(|| "OpenAI-compatible stream reported an error".to_owned());
1432 let kind = completion_error_kind(error.code.as_ref(), &message);
1433 log::warn!("OpenAI in-band stream error kind={kind:?} message={message}");
1434 results.push(SseProcessResult::Error { message, kind });
1435 return results;
1436 }
1437
1438 if let Some(choice) = chunk.choices.into_iter().next() {
1440 let content = choice.delta.content.filter(|content| !content.is_empty());
1441 let refusal = choice.delta.refusal.filter(|refusal| !refusal.is_empty());
1442 let has_visible_output = content.is_some() || refusal.is_some();
1443
1444 if let Some(content) = content {
1445 results.push(SseProcessResult::TextDelta(content));
1446 }
1447 if let Some(refusal) = refusal {
1448 results.push(SseProcessResult::RefusalDelta(refusal));
1449 }
1450 if !has_visible_output
1451 && let Some(reasoning) = choice
1452 .delta
1453 .reasoning_content
1454 .as_deref()
1455 .or(choice.delta.reasoning.as_deref())
1456 .filter(|reasoning| !reasoning.is_empty())
1457 {
1458 results.push(SseProcessResult::ThinkingDelta(reasoning.to_owned()));
1459 }
1460
1461 if let Some(tc_deltas) = choice.delta.tool_calls {
1463 for tc in tc_deltas {
1464 results.push(SseProcessResult::ToolCallUpdate {
1465 index: tc.index,
1466 id: tc.id,
1467 name: tc.function.as_ref().and_then(|f| f.name.clone()),
1468 arguments: tc.function.as_ref().and_then(|f| f.arguments.clone()),
1469 });
1470 }
1471 }
1472
1473 if let Some(finish_reason) = choice.finish_reason {
1475 results.push(SseProcessResult::Done(map_finish_reason(&finish_reason)));
1476 }
1477 }
1478
1479 results
1480}
1481
1482fn use_max_tokens_alias(base_url: &str) -> bool {
1483 base_url.contains("moonshot.ai")
1484 || base_url.contains("api.z.ai")
1485 || base_url.contains("minimax.io")
1486}
1487
1488const fn use_stream_usage_options(_base_url: &str) -> bool {
1493 true
1494}
1495
1496fn use_openrouter_usage_options(base_url: &str) -> bool {
1499 base_url.contains("openrouter.ai")
1500}
1501
1502fn fallback_stream_stop_reason(
1505 tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
1506) -> StopReason {
1507 if tool_calls.is_empty() {
1508 StopReason::EndTurn
1509 } else {
1510 StopReason::ToolUse
1511 }
1512}
1513
1514fn decode_chat_response(
1517 status: StatusCode,
1518 bytes: &[u8],
1519 retry_after: Option<std::time::Duration>,
1520) -> Result<ChatOutcome> {
1521 if status == StatusCode::TOO_MANY_REQUESTS {
1522 let retry_after = retry_after
1523 .or_else(|| crate::retry_hints::openai_retry_delay(&String::from_utf8_lossy(bytes)));
1524 return Ok(ChatOutcome::RateLimited(retry_after));
1525 }
1526
1527 if status.is_server_error() {
1528 let body = String::from_utf8_lossy(bytes);
1529 log::error!("OpenAI server error status={status} body={body}");
1530 return Ok(ChatOutcome::ServerError(body.into_owned()));
1531 }
1532
1533 if status.is_client_error() {
1534 let body = String::from_utf8_lossy(bytes);
1535 log::warn!("OpenAI client error status={status} body={body}");
1536 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
1537 }
1538
1539 let api_response: ApiChatResponse = serde_json::from_slice(bytes)
1540 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
1541
1542 let choice = api_response
1543 .choices
1544 .into_iter()
1545 .next()
1546 .ok_or_else(|| anyhow::anyhow!("no choices in response"))?;
1547
1548 let stop_reason = if refusal_text(&choice.message).is_some() {
1549 Some(StopReason::Refusal)
1550 } else {
1551 choice.finish_reason.as_deref().map(map_finish_reason)
1552 };
1553 let mut content = build_content_blocks(&choice.message);
1554 if !matches!(stop_reason, Some(StopReason::ToolUse)) {
1555 content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
1556 }
1557
1558 Ok(ChatOutcome::Success(ChatResponse {
1559 id: api_response.id,
1560 content,
1561 model: api_response.model,
1562 stop_reason,
1563 usage: Usage {
1564 input_tokens: api_response.usage.prompt_tokens,
1565 output_tokens: api_response.usage.completion_tokens,
1566 cached_input_tokens: api_response
1567 .usage
1568 .prompt_tokens_details
1569 .as_ref()
1570 .map_or(0, |details| details.cached_tokens),
1571 cache_creation_input_tokens: api_response
1572 .usage
1573 .prompt_tokens_details
1574 .as_ref()
1575 .map_or(0, |details| details.cache_write_tokens),
1576 },
1577 }))
1578}
1579
1580fn map_finish_reason(finish_reason: &str) -> StopReason {
1581 match finish_reason {
1582 "stop" => StopReason::EndTurn,
1583 "tool_calls" => StopReason::ToolUse,
1584 "length" => StopReason::MaxTokens,
1585 "content_filter" | "refusal" | "sensitive" => StopReason::Refusal,
1586 "network_error" => StopReason::Unknown,
1587 unknown => {
1588 log::debug!("Unknown finish_reason from OpenAI-compatible API: {unknown}");
1589 StopReason::Unknown
1590 }
1591 }
1592}
1593
1594fn build_chat_api_reasoning(
1595 config: Option<&OpenAIReasoningConfig>,
1596 first_party_wire: bool,
1597) -> Option<ApiChatReasoning> {
1598 config
1599 .and_then(OpenAIReasoningConfig::effort)
1600 .map(|effort| {
1601 if first_party_wire {
1602 ApiChatReasoning {
1603 reasoning_effort: Some(effort),
1604 reasoning: None,
1605 }
1606 } else {
1607 ApiChatReasoning {
1608 reasoning_effort: None,
1609 reasoning: Some(ApiCompatibleReasoning { effort }),
1610 }
1611 }
1612 })
1613}
1614
1615const fn api_role(role: agent_sdk_foundation::llm::Role) -> ApiRole {
1616 match role {
1617 agent_sdk_foundation::llm::Role::User => ApiRole::User,
1618 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1619 }
1620}
1621
1622fn append_block_messages(
1629 messages: &mut Vec<ApiMessage>,
1630 role: agent_sdk_foundation::llm::Role,
1631 blocks: &[ContentBlock],
1632) {
1633 let mut text_parts = Vec::new();
1634 let mut thinking_parts = Vec::new();
1635 let mut tool_calls = Vec::new();
1636
1637 for block in blocks {
1638 match block {
1639 ContentBlock::Text { text } => text_parts.push(text.clone()),
1640 ContentBlock::Thinking { thinking, .. } => {
1641 thinking_parts.push(thinking.clone());
1646 }
1647 ContentBlock::RedactedThinking { .. }
1648 | ContentBlock::Image { .. }
1649 | ContentBlock::Document { .. } => {
1650 }
1652 ContentBlock::ToolUse {
1653 id, name, input, ..
1654 } => {
1655 tool_calls.push(ApiToolCall {
1656 id: id.clone(),
1657 r#type: "function".to_owned(),
1658 function: ApiFunctionCall {
1659 name: name.clone(),
1660 arguments: serde_json::to_string(input).unwrap_or_else(|_| "{}".to_owned()),
1661 },
1662 });
1663 }
1664 ContentBlock::ToolResult {
1665 tool_use_id,
1666 content,
1667 ..
1668 } => {
1669 messages.push(ApiMessage {
1671 role: ApiRole::Tool,
1672 content: Some(content.clone()),
1673 reasoning_content: None,
1674 tool_calls: None,
1675 tool_call_id: Some(tool_use_id.clone()),
1676 prompt_cache_breakpoint: false,
1677 });
1678 }
1679 _ => log::warn!("Skipping unrecognized OpenAI content block"),
1682 }
1683 }
1684
1685 let role = api_role(role);
1686
1687 let reasoning_content =
1695 if role == ApiRole::Assistant && !thinking_parts.is_empty() && !tool_calls.is_empty() {
1696 Some(thinking_parts.join("\n"))
1697 } else {
1698 None
1699 };
1700
1701 let has_payload =
1705 !text_parts.is_empty() || !tool_calls.is_empty() || reasoning_content.is_some();
1706 if has_payload && (role == ApiRole::Assistant || !text_parts.is_empty()) {
1707 messages.push(ApiMessage {
1708 role,
1709 content: if text_parts.is_empty() {
1710 None
1711 } else {
1712 Some(text_parts.join("\n"))
1713 },
1714 reasoning_content,
1715 tool_calls: if tool_calls.is_empty() {
1716 None
1717 } else {
1718 Some(tool_calls)
1719 },
1720 tool_call_id: None,
1721 prompt_cache_breakpoint: false,
1722 });
1723 }
1724}
1725
1726#[cfg(test)]
1727fn build_api_messages(request: &ChatRequest) -> Vec<ApiMessage> {
1728 build_api_messages_with_cache(request, 0)
1729}
1730
1731fn build_api_messages_with_cache(
1732 request: &ChatRequest,
1733 explicit_cache_breakpoints: usize,
1734) -> Vec<ApiMessage> {
1735 let mut messages = Vec::new();
1736
1737 if !request.system.is_empty() {
1739 messages.push(ApiMessage {
1740 role: ApiRole::System,
1741 content: Some(request.system.clone()),
1742 reasoning_content: None,
1743 tool_calls: None,
1744 tool_call_id: None,
1745 prompt_cache_breakpoint: false,
1746 });
1747 }
1748
1749 for msg in &request.messages {
1751 match &msg.content {
1752 Content::Text(text) => {
1753 messages.push(ApiMessage {
1754 role: api_role(msg.role),
1755 content: Some(text.clone()),
1756 reasoning_content: None,
1757 tool_calls: None,
1758 tool_call_id: None,
1759 prompt_cache_breakpoint: false,
1760 });
1761 }
1762 Content::Blocks(blocks) => append_block_messages(&mut messages, msg.role, blocks),
1763 }
1764 }
1765
1766 apply_chat_cache_breakpoints(&mut messages, explicit_cache_breakpoints);
1767 messages
1768}
1769
1770fn apply_chat_cache_breakpoints(messages: &mut [ApiMessage], max_breakpoints: usize) {
1771 if max_breakpoints == 0 {
1772 return;
1773 }
1774
1775 let max_breakpoints = max_breakpoints.min(4);
1776 let message_indices: Vec<usize> = messages
1777 .iter()
1778 .enumerate()
1779 .filter_map(|(index, message)| message.content.as_ref().map(|_| index))
1780 .collect();
1781
1782 let mut selected = Vec::with_capacity(max_breakpoints);
1783 if let Some(system_index) = message_indices
1787 .iter()
1788 .copied()
1789 .find(|index| messages[*index].role == ApiRole::System)
1790 {
1791 selected.push(system_index);
1792 }
1793
1794 let remaining = max_breakpoints.saturating_sub(selected.len());
1795 let tail_candidates: Vec<usize> = message_indices
1796 .into_iter()
1797 .filter(|index| !selected.contains(index))
1798 .collect();
1799 let keep_from = tail_candidates.len().saturating_sub(remaining);
1800 selected.extend_from_slice(&tail_candidates[keep_from..]);
1801
1802 for index in selected {
1803 messages[index].prompt_cache_breakpoint = true;
1804 }
1805}
1806
1807fn convert_tool(t: agent_sdk_foundation::llm::Tool, normalize_for_openai_strict: bool) -> ApiTool {
1808 let mut parameters = t.input_schema;
1809 let strict =
1810 (normalize_for_openai_strict && normalize_strict_schema(&mut parameters)).then_some(true);
1811 ApiTool {
1812 r#type: "function".to_owned(),
1813 function: ApiFunction {
1814 name: t.name,
1815 description: t.description,
1816 parameters,
1817 strict,
1818 },
1819 }
1820}
1821
1822fn reasoning_text(message: &ApiResponseMessage) -> Option<&str> {
1827 message
1828 .reasoning_content
1829 .as_deref()
1830 .or(message.reasoning.as_deref())
1831 .filter(|r| !r.is_empty())
1832}
1833
1834fn refusal_text(message: &ApiResponseMessage) -> Option<&str> {
1835 message
1836 .refusal
1837 .as_deref()
1838 .filter(|refusal| !refusal.is_empty())
1839}
1840
1841fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
1842 let mut blocks = Vec::new();
1843
1844 let content = message
1845 .content
1846 .as_ref()
1847 .filter(|content| !content.is_empty());
1848 let refusal = refusal_text(message);
1849 let has_visible_output = content.is_some() || refusal.is_some();
1850 if let Some(content) = content {
1851 blocks.push(ContentBlock::Text {
1852 text: content.clone(),
1853 });
1854 }
1855 if let Some(refusal) = refusal {
1856 blocks.push(ContentBlock::Text {
1857 text: refusal.to_owned(),
1858 });
1859 }
1860 if !has_visible_output && let Some(reasoning) = reasoning_text(message) {
1861 blocks.push(ContentBlock::Thinking {
1868 thinking: reasoning.to_owned(),
1869 signature: None,
1870 });
1871 }
1872
1873 if let Some(tool_calls) = &message.tool_calls {
1875 for tc in tool_calls {
1876 let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
1877 .unwrap_or_else(|_| serde_json::json!({}));
1878 blocks.push(ContentBlock::ToolUse {
1879 id: tc.id.clone(),
1880 name: tc.function.name.clone(),
1881 input,
1882 thought_signature: None,
1883 });
1884 }
1885 }
1886
1887 blocks
1888}
1889
1890#[derive(Serialize)]
1895struct ApiChatRequest<'a> {
1896 model: &'a str,
1897 messages: &'a [ApiMessage],
1898 #[serde(skip_serializing_if = "Option::is_none")]
1899 max_completion_tokens: Option<u32>,
1900 #[serde(skip_serializing_if = "Option::is_none")]
1901 max_tokens: Option<u32>,
1902 #[serde(skip_serializing_if = "Option::is_none")]
1903 tools: Option<&'a [ApiTool]>,
1904 #[serde(skip_serializing_if = "Option::is_none")]
1905 tool_choice: Option<ApiToolChoice>,
1906 #[serde(flatten)]
1907 reasoning: Option<ApiChatReasoning>,
1908 #[serde(skip_serializing_if = "Option::is_none")]
1909 response_format: Option<ApiResponseFormat>,
1910 #[serde(skip_serializing_if = "Option::is_none")]
1911 verbosity: Option<OpenAITextVerbosity>,
1912 #[serde(skip_serializing_if = "Option::is_none")]
1913 prompt_cache_options: Option<ApiPromptCacheOptions>,
1914 #[serde(skip_serializing_if = "Option::is_none")]
1915 store: Option<bool>,
1916 #[serde(skip_serializing_if = "Option::is_none")]
1917 parallel_tool_calls: Option<bool>,
1918 #[serde(skip_serializing_if = "Option::is_none")]
1919 safety_identifier: Option<&'a str>,
1920 #[serde(skip_serializing_if = "Option::is_none")]
1921 prompt_cache_key: Option<&'a str>,
1922}
1923
1924#[derive(Serialize)]
1925struct ApiChatRequestStreaming<'a> {
1926 model: &'a str,
1927 messages: &'a [ApiMessage],
1928 #[serde(skip_serializing_if = "Option::is_none")]
1929 max_completion_tokens: Option<u32>,
1930 #[serde(skip_serializing_if = "Option::is_none")]
1931 max_tokens: Option<u32>,
1932 #[serde(skip_serializing_if = "Option::is_none")]
1933 tools: Option<&'a [ApiTool]>,
1934 #[serde(skip_serializing_if = "Option::is_none")]
1935 tool_choice: Option<ApiToolChoice>,
1936 #[serde(flatten)]
1937 reasoning: Option<ApiChatReasoning>,
1938 #[serde(skip_serializing_if = "Option::is_none")]
1939 response_format: Option<ApiResponseFormat>,
1940 #[serde(skip_serializing_if = "Option::is_none")]
1941 verbosity: Option<OpenAITextVerbosity>,
1942 #[serde(skip_serializing_if = "Option::is_none")]
1943 prompt_cache_options: Option<ApiPromptCacheOptions>,
1944 #[serde(skip_serializing_if = "Option::is_none")]
1945 store: Option<bool>,
1946 #[serde(skip_serializing_if = "Option::is_none")]
1947 parallel_tool_calls: Option<bool>,
1948 #[serde(skip_serializing_if = "Option::is_none")]
1949 safety_identifier: Option<&'a str>,
1950 #[serde(skip_serializing_if = "Option::is_none")]
1951 prompt_cache_key: Option<&'a str>,
1952 #[serde(skip_serializing_if = "Option::is_none")]
1953 stream_options: Option<ApiStreamOptions>,
1954 #[serde(skip_serializing_if = "Option::is_none")]
1955 usage: Option<ApiOpenRouterUsageOptions>,
1956 stream: bool,
1957}
1958
1959#[derive(Serialize)]
1964#[serde(untagged)]
1965enum ApiToolChoice {
1966 Mode(&'static str),
1967 Named {
1968 #[serde(rename = "type")]
1969 choice_type: &'static str,
1970 function: ApiToolChoiceFunction,
1971 },
1972 AllowedTools {
1973 #[serde(rename = "type")]
1974 choice_type: &'static str,
1975 allowed_tools: ApiChatAllowedTools,
1976 },
1977}
1978
1979#[derive(Serialize)]
1980struct ApiToolChoiceFunction {
1981 name: String,
1982}
1983
1984#[derive(Serialize)]
1985struct ApiChatAllowedTools {
1986 mode: OpenAIAllowedToolsMode,
1987 tools: Vec<ApiChatAllowedTool>,
1988}
1989
1990#[derive(Serialize)]
1991struct ApiChatAllowedTool {
1992 #[serde(rename = "type")]
1993 tool_type: &'static str,
1994 function: ApiToolChoiceFunction,
1995}
1996
1997impl ApiToolChoice {
1998 fn from_tool_choice(tc: &ToolChoice) -> Self {
1999 match tc {
2000 ToolChoice::Auto => Self::Mode("auto"),
2001 ToolChoice::Tool(name) => Self::Named {
2002 choice_type: "function",
2003 function: ApiToolChoiceFunction { name: name.clone() },
2004 },
2005 }
2006 }
2007
2008 fn from_openai_tool_choice(choice: &OpenAIToolChoice) -> Self {
2009 match choice {
2010 OpenAIToolChoice::None => Self::Mode("none"),
2011 OpenAIToolChoice::Auto => Self::Mode("auto"),
2012 OpenAIToolChoice::Required => Self::Mode("required"),
2013 OpenAIToolChoice::Function(name) => Self::Named {
2014 choice_type: "function",
2015 function: ApiToolChoiceFunction { name: name.clone() },
2016 },
2017 OpenAIToolChoice::AllowedTools { mode, tools } => Self::AllowedTools {
2018 choice_type: "allowed_tools",
2019 allowed_tools: ApiChatAllowedTools {
2020 mode: *mode,
2021 tools: tools
2022 .iter()
2023 .map(|name| ApiChatAllowedTool {
2024 tool_type: "function",
2025 function: ApiToolChoiceFunction { name: name.clone() },
2026 })
2027 .collect(),
2028 },
2029 },
2030 }
2031 }
2032}
2033
2034#[derive(Serialize)]
2038struct ApiResponseFormat {
2039 #[serde(rename = "type")]
2040 format_type: &'static str,
2041 json_schema: ApiJsonSchema,
2042}
2043
2044#[derive(Serialize)]
2045struct ApiJsonSchema {
2046 name: String,
2047 schema: serde_json::Value,
2048 strict: bool,
2049}
2050
2051impl ApiResponseFormat {
2052 fn from_response_format(
2053 rf: &agent_sdk_foundation::llm::ResponseFormat,
2054 normalize_for_openai_strict: bool,
2055 ) -> Self {
2056 let mut schema = rf.schema.clone();
2057 if rf.strict && normalize_for_openai_strict {
2058 let _ = normalize_strict_schema(&mut schema);
2059 }
2060 Self {
2061 format_type: "json_schema",
2062 json_schema: ApiJsonSchema {
2063 name: rf.name.clone(),
2064 schema,
2065 strict: rf.strict,
2066 },
2067 }
2068 }
2069}
2070
2071#[derive(Clone, Copy, Serialize)]
2072struct ApiStreamOptions {
2073 include_usage: bool,
2074}
2075
2076#[derive(Clone, Copy, Serialize)]
2079struct ApiOpenRouterUsageOptions {
2080 include: bool,
2081}
2082
2083#[derive(Serialize)]
2084struct ApiChatReasoning {
2085 #[serde(skip_serializing_if = "Option::is_none")]
2086 reasoning_effort: Option<OpenAIReasoningEffort>,
2087 #[serde(skip_serializing_if = "Option::is_none")]
2088 reasoning: Option<ApiCompatibleReasoning>,
2089}
2090
2091#[derive(Serialize)]
2092struct ApiCompatibleReasoning {
2093 effort: OpenAIReasoningEffort,
2094}
2095
2096#[derive(Clone, Copy, Serialize)]
2097struct ApiPromptCacheOptions {
2098 #[serde(skip_serializing_if = "Option::is_none")]
2099 mode: Option<OpenAIPromptCacheMode>,
2100 #[serde(skip_serializing_if = "Option::is_none")]
2101 ttl: Option<OpenAIPromptCacheTtl>,
2102}
2103
2104#[derive(Clone, Copy)]
2105struct ChatPromptCachePlan {
2106 options: Option<ApiPromptCacheOptions>,
2107 explicit_breakpoints: usize,
2108}
2109
2110impl ApiPromptCacheOptions {
2111 const fn new(mode: OpenAIPromptCacheMode) -> Self {
2112 Self {
2113 mode: Some(mode),
2114 ttl: None,
2115 }
2116 }
2117
2118 fn from_config(config: Option<&OpenAIReasoningConfig>) -> Option<Self> {
2119 let config = config?;
2120 let options = Self {
2121 mode: config.prompt_cache_mode(),
2122 ttl: config.prompt_cache_ttl(),
2123 };
2124 (options.mode.is_some() || options.ttl.is_some()).then_some(options)
2125 }
2126}
2127
2128struct ApiMessage {
2129 role: ApiRole,
2130 content: Option<String>,
2131 reasoning_content: Option<String>,
2138 tool_calls: Option<Vec<ApiToolCall>>,
2139 tool_call_id: Option<String>,
2140 prompt_cache_breakpoint: bool,
2141}
2142
2143impl Serialize for ApiMessage {
2144 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2145 where
2146 S: serde::Serializer,
2147 {
2148 let mut message = serializer.serialize_struct("ApiMessage", 5)?;
2149 message.serialize_field("role", &self.role)?;
2150 if let Some(content) = self.content.as_deref() {
2151 if self.prompt_cache_breakpoint {
2152 message.serialize_field(
2153 "content",
2154 &[ApiChatTextPart {
2155 part_type: "text",
2156 text: content,
2157 prompt_cache_breakpoint: ApiChatPromptCacheBreakpoint {
2158 mode: OpenAIPromptCacheMode::Explicit,
2159 },
2160 }],
2161 )?;
2162 } else {
2163 message.serialize_field("content", content)?;
2164 }
2165 }
2166 if let Some(reasoning_content) = self.reasoning_content.as_deref() {
2167 message.serialize_field("reasoning_content", reasoning_content)?;
2168 }
2169 if let Some(tool_calls) = self.tool_calls.as_ref() {
2170 message.serialize_field("tool_calls", tool_calls)?;
2171 }
2172 if let Some(tool_call_id) = self.tool_call_id.as_deref() {
2173 message.serialize_field("tool_call_id", tool_call_id)?;
2174 }
2175 message.end()
2176 }
2177}
2178
2179#[derive(Serialize)]
2180struct ApiChatTextPart<'a> {
2181 #[serde(rename = "type")]
2182 part_type: &'static str,
2183 text: &'a str,
2184 prompt_cache_breakpoint: ApiChatPromptCacheBreakpoint,
2185}
2186
2187#[derive(Clone, Copy, Serialize)]
2188struct ApiChatPromptCacheBreakpoint {
2189 mode: OpenAIPromptCacheMode,
2190}
2191
2192#[derive(Debug, Serialize, PartialEq, Eq)]
2193#[serde(rename_all = "lowercase")]
2194enum ApiRole {
2195 System,
2196 User,
2197 Assistant,
2198 Tool,
2199}
2200
2201#[derive(Serialize)]
2202struct ApiToolCall {
2203 id: String,
2204 r#type: String,
2205 function: ApiFunctionCall,
2206}
2207
2208#[derive(Serialize)]
2209struct ApiFunctionCall {
2210 name: String,
2211 arguments: String,
2212}
2213
2214#[derive(Serialize)]
2215struct ApiTool {
2216 r#type: String,
2217 function: ApiFunction,
2218}
2219
2220#[derive(Serialize)]
2221struct ApiFunction {
2222 name: String,
2223 description: String,
2224 parameters: serde_json::Value,
2225 #[serde(skip_serializing_if = "Option::is_none")]
2226 strict: Option<bool>,
2227}
2228
2229#[derive(Deserialize)]
2234struct ApiChatResponse {
2235 id: String,
2236 choices: Vec<ApiChoice>,
2237 model: String,
2238 usage: ApiUsage,
2239}
2240
2241#[derive(Deserialize)]
2242struct ApiChoice {
2243 message: ApiResponseMessage,
2244 finish_reason: Option<String>,
2245}
2246
2247#[derive(Deserialize)]
2248struct ApiResponseMessage {
2249 content: Option<String>,
2250 #[serde(default)]
2251 refusal: Option<String>,
2252 tool_calls: Option<Vec<ApiResponseToolCall>>,
2253 #[serde(default)]
2256 reasoning_content: Option<String>,
2257 #[serde(default)]
2260 reasoning: Option<String>,
2261}
2262
2263#[derive(Deserialize)]
2264struct ApiResponseToolCall {
2265 id: String,
2266 function: ApiResponseFunctionCall,
2267}
2268
2269#[derive(Deserialize)]
2270struct ApiResponseFunctionCall {
2271 name: String,
2272 arguments: String,
2273}
2274
2275#[derive(Deserialize)]
2276struct ApiUsage {
2277 #[serde(deserialize_with = "deserialize_u32_from_number")]
2278 prompt_tokens: u32,
2279 #[serde(deserialize_with = "deserialize_u32_from_number")]
2280 completion_tokens: u32,
2281 #[serde(default)]
2282 prompt_tokens_details: Option<ApiPromptTokensDetails>,
2283}
2284
2285#[derive(Deserialize)]
2286struct ApiPromptTokensDetails {
2287 #[serde(default, deserialize_with = "deserialize_u32_from_number")]
2288 cached_tokens: u32,
2289 #[serde(default, deserialize_with = "deserialize_u32_from_number")]
2290 cache_write_tokens: u32,
2291}
2292
2293struct ToolCallAccumulator {
2299 id: String,
2300 name: String,
2301 arguments: String,
2302}
2303
2304#[derive(Deserialize)]
2306struct SseChunk {
2307 #[serde(default)]
2311 choices: Vec<SseChoice>,
2312 #[serde(default)]
2313 usage: Option<SseUsage>,
2314 #[serde(default)]
2318 error: Option<SseError>,
2319}
2320
2321#[derive(Deserialize)]
2322struct SseError {
2323 #[serde(default)]
2324 message: Option<String>,
2325 #[serde(default)]
2329 code: Option<serde_json::Value>,
2330}
2331
2332#[derive(Deserialize)]
2333struct SseChoice {
2334 delta: SseDelta,
2335 finish_reason: Option<String>,
2336}
2337
2338#[derive(Deserialize)]
2339struct SseDelta {
2340 content: Option<String>,
2341 #[serde(default)]
2342 refusal: Option<String>,
2343 tool_calls: Option<Vec<SseToolCallDelta>>,
2344 #[serde(default)]
2347 reasoning_content: Option<String>,
2348 #[serde(default)]
2351 reasoning: Option<String>,
2352}
2353
2354#[derive(Deserialize)]
2355struct SseToolCallDelta {
2356 index: usize,
2357 id: Option<String>,
2358 function: Option<SseFunctionDelta>,
2359}
2360
2361#[derive(Deserialize)]
2362struct SseFunctionDelta {
2363 name: Option<String>,
2364 arguments: Option<String>,
2365}
2366
2367#[derive(Deserialize)]
2368struct SseUsage {
2369 #[serde(deserialize_with = "deserialize_u32_from_number")]
2370 prompt_tokens: u32,
2371 #[serde(deserialize_with = "deserialize_u32_from_number")]
2372 completion_tokens: u32,
2373 #[serde(default)]
2374 prompt_tokens_details: Option<ApiPromptTokensDetails>,
2375}
2376
2377fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
2378where
2379 D: serde::Deserializer<'de>,
2380{
2381 #[derive(Deserialize)]
2382 #[serde(untagged)]
2383 enum NumberLike {
2384 U64(u64),
2385 F64(f64),
2386 }
2387
2388 match NumberLike::deserialize(deserializer)? {
2389 NumberLike::U64(v) => u32::try_from(v)
2390 .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
2391 NumberLike::F64(v) => {
2392 if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
2393 v.to_string().parse::<u32>().map_err(|e| {
2394 D::Error::custom(format!(
2395 "failed to convert integer-compatible token count {v} to u32: {e}"
2396 ))
2397 })
2398 } else {
2399 Err(D::Error::custom(format!(
2400 "token count must be a non-negative integer-compatible number, got {v}"
2401 )))
2402 }
2403 }
2404 }
2405}
2406
2407#[cfg(test)]
2408mod tests {
2409 use super::*;
2410 use anyhow::Context as _;
2411
2412 const OPENAI_MODELS_FIXTURE: &str = r#"{
2413 "object": "list",
2414 "data": [
2415 {"id": "gpt-5.4", "object": "model", "owned_by": "openai"},
2416 {"id": "gpt-4o", "object": "model", "owned_by": "openai"}
2417 ]
2418 }"#;
2419
2420 fn function_tool(name: &str) -> agent_sdk_foundation::llm::Tool {
2421 agent_sdk_foundation::llm::Tool {
2422 name: name.to_owned(),
2423 description: format!("Call {name}"),
2424 input_schema: serde_json::json!({"type": "object"}),
2425 display_name: name.to_owned(),
2426 tier: agent_sdk_foundation::ToolTier::Observe,
2427 }
2428 }
2429
2430 #[test]
2431 fn parse_models_list_reads_ids() -> anyhow::Result<()> {
2432 let models = parse_models_list(OPENAI_MODELS_FIXTURE)?;
2433 assert_eq!(models.len(), 2);
2434 assert_eq!(models[0].id, "gpt-5.4");
2435 assert_eq!(models[1].id, "gpt-4o");
2436 assert_eq!(models[0].display_name, None);
2438 assert_eq!(models[0].context_window, None);
2439 Ok(())
2440 }
2441
2442 #[test]
2447 fn test_new_creates_provider_with_custom_model() {
2448 let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
2449
2450 assert_eq!(provider.model(), "custom-model");
2451 assert_eq!(provider.provider(), "openai");
2452 assert_eq!(provider.base_url, DEFAULT_BASE_URL);
2453 }
2454
2455 #[test]
2456 fn test_with_base_url_creates_provider_with_custom_url() {
2457 let provider = OpenAIProvider::with_base_url(
2458 "test-api-key".to_string(),
2459 "llama3".to_string(),
2460 "http://localhost:11434/v1".to_string(),
2461 );
2462
2463 assert_eq!(provider.model(), "llama3");
2464 assert_eq!(provider.base_url, "http://localhost:11434/v1");
2465 }
2466
2467 #[test]
2468 fn test_gpt4o_factory_creates_gpt4o_provider() {
2469 let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
2470
2471 assert_eq!(provider.model(), MODEL_GPT4O);
2472 assert_eq!(provider.provider(), "openai");
2473 }
2474
2475 #[test]
2476 fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
2477 let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
2478
2479 assert_eq!(provider.model(), MODEL_GPT4O_MINI);
2480 assert_eq!(provider.provider(), "openai");
2481 }
2482
2483 #[test]
2484 fn test_gpt52_thinking_factory_creates_provider() {
2485 let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
2486
2487 assert_eq!(provider.model(), MODEL_GPT52_THINKING);
2488 assert_eq!(provider.provider(), "openai");
2489 }
2490
2491 #[test]
2492 fn test_gpt54_factory_creates_provider() {
2493 let provider = OpenAIProvider::gpt54("test-api-key".to_string());
2494
2495 assert_eq!(provider.model(), MODEL_GPT54);
2496 assert_eq!(provider.provider(), "openai");
2497 }
2498
2499 #[test]
2500 fn test_gpt56_factories_create_expected_providers() {
2501 for (provider, expected_model) in [
2502 (
2503 OpenAIProvider::gpt56("test-api-key".to_string()),
2504 MODEL_GPT56,
2505 ),
2506 (
2507 OpenAIProvider::gpt56_sol("test-api-key".to_string()),
2508 MODEL_GPT56_SOL,
2509 ),
2510 (
2511 OpenAIProvider::gpt56_terra("test-api-key".to_string()),
2512 MODEL_GPT56_TERRA,
2513 ),
2514 (
2515 OpenAIProvider::gpt56_luna("test-api-key".to_string()),
2516 MODEL_GPT56_LUNA,
2517 ),
2518 ] {
2519 assert_eq!(provider.model(), expected_model);
2520 assert_eq!(provider.provider(), "openai");
2521 assert_eq!(provider.default_max_tokens(), 128_000);
2522 }
2523 }
2524
2525 #[test]
2526 fn test_gpt53_codex_factory_creates_provider() {
2527 let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
2528
2529 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2530 assert_eq!(provider.provider(), "openai");
2531 }
2532
2533 #[test]
2534 fn test_codex_factory_points_to_latest_codex_model() {
2535 let provider = OpenAIProvider::codex("test-api-key".to_string());
2536
2537 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2538 assert_eq!(provider.provider(), "openai");
2539 }
2540
2541 #[test]
2542 fn test_gpt5_factory_creates_gpt5_provider() {
2543 let provider = OpenAIProvider::gpt5("test-api-key".to_string());
2544
2545 assert_eq!(provider.model(), MODEL_GPT5);
2546 assert_eq!(provider.provider(), "openai");
2547 }
2548
2549 #[test]
2550 fn test_gpt5_mini_factory_creates_provider() {
2551 let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
2552
2553 assert_eq!(provider.model(), MODEL_GPT5_MINI);
2554 assert_eq!(provider.provider(), "openai");
2555 }
2556
2557 #[test]
2558 fn test_o3_factory_creates_o3_provider() {
2559 let provider = OpenAIProvider::o3("test-api-key".to_string());
2560
2561 assert_eq!(provider.model(), MODEL_O3);
2562 assert_eq!(provider.provider(), "openai");
2563 }
2564
2565 #[test]
2566 fn test_o4_mini_factory_creates_o4_mini_provider() {
2567 let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
2568
2569 assert_eq!(provider.model(), MODEL_O4_MINI);
2570 assert_eq!(provider.provider(), "openai");
2571 }
2572
2573 #[test]
2574 fn test_o1_factory_creates_o1_provider() {
2575 let provider = OpenAIProvider::o1("test-api-key".to_string());
2576
2577 assert_eq!(provider.model(), MODEL_O1);
2578 assert_eq!(provider.provider(), "openai");
2579 }
2580
2581 #[test]
2582 fn test_gpt41_factory_creates_gpt41_provider() {
2583 let provider = OpenAIProvider::gpt41("test-api-key".to_string());
2584
2585 assert_eq!(provider.model(), MODEL_GPT41);
2586 assert_eq!(provider.provider(), "openai");
2587 }
2588
2589 #[test]
2590 fn test_kimi_factory_creates_provider_with_kimi_base_url() {
2591 let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
2592
2593 assert_eq!(provider.model(), "kimi-custom");
2594 assert_eq!(provider.base_url, BASE_URL_KIMI);
2595 assert_eq!(provider.provider(), "openai");
2596 }
2597
2598 #[test]
2599 fn test_kimi_k2_5_factory_creates_provider() {
2600 let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
2601
2602 assert_eq!(provider.model(), MODEL_KIMI_K2_5);
2603 assert_eq!(provider.base_url, BASE_URL_KIMI);
2604 assert_eq!(provider.provider(), "openai");
2605 }
2606
2607 #[test]
2608 fn test_kimi_k2_thinking_factory_creates_provider() {
2609 let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
2610
2611 assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
2612 assert_eq!(provider.base_url, BASE_URL_KIMI);
2613 assert_eq!(provider.provider(), "openai");
2614 }
2615
2616 #[test]
2617 fn test_zai_factory_creates_provider_with_zai_base_url() {
2618 let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
2619
2620 assert_eq!(provider.model(), "glm-custom");
2621 assert_eq!(provider.base_url, BASE_URL_ZAI);
2622 assert_eq!(provider.provider(), "openai");
2623 }
2624
2625 #[test]
2626 fn test_zai_glm5_factory_creates_provider() {
2627 let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
2628
2629 assert_eq!(provider.model(), MODEL_ZAI_GLM5);
2630 assert_eq!(provider.base_url, BASE_URL_ZAI);
2631 assert_eq!(provider.provider(), "openai");
2632 }
2633
2634 #[test]
2635 fn test_minimax_factory_creates_provider_with_minimax_base_url() {
2636 let provider =
2637 OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
2638
2639 assert_eq!(provider.model(), "minimax-custom");
2640 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
2641 assert_eq!(provider.provider(), "openai");
2642 }
2643
2644 #[test]
2645 fn test_minimax_m2_5_factory_creates_provider() {
2646 let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
2647
2648 assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
2649 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
2650 assert_eq!(provider.provider(), "openai");
2651 }
2652
2653 #[test]
2658 fn test_model_constants_have_expected_values() {
2659 assert_eq!(MODEL_GPT56, "gpt-5.6");
2661 assert_eq!(MODEL_GPT56_SOL, "gpt-5.6-sol");
2662 assert_eq!(MODEL_GPT56_TERRA, "gpt-5.6-terra");
2663 assert_eq!(MODEL_GPT56_LUNA, "gpt-5.6-luna");
2664 assert_eq!(MODEL_GPT54, "gpt-5.4");
2666 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2667 assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
2669 assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
2670 assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
2671 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2672 assert_eq!(MODEL_GPT5, "gpt-5");
2674 assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
2675 assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
2676 assert_eq!(MODEL_O3, "o3");
2678 assert_eq!(MODEL_O3_MINI, "o3-mini");
2679 assert_eq!(MODEL_O4_MINI, "o4-mini");
2680 assert_eq!(MODEL_O1, "o1");
2681 assert_eq!(MODEL_O1_MINI, "o1-mini");
2682 assert_eq!(MODEL_GPT41, "gpt-4.1");
2684 assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
2685 assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
2686 assert_eq!(MODEL_GPT4O, "gpt-4o");
2688 assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
2689 assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
2691 assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
2692 assert_eq!(MODEL_ZAI_GLM5, "glm-5");
2693 assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
2694 assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
2695 assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
2696 assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
2697 }
2698
2699 #[test]
2704 fn test_provider_is_cloneable() {
2705 let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
2706 let cloned = provider.clone();
2707
2708 assert_eq!(provider.model(), cloned.model());
2709 assert_eq!(provider.provider(), cloned.provider());
2710 assert_eq!(provider.base_url, cloned.base_url);
2711 }
2712
2713 #[test]
2718 fn test_api_role_serialization() {
2719 let system_role = ApiRole::System;
2720 let user_role = ApiRole::User;
2721 let assistant_role = ApiRole::Assistant;
2722 let tool_role = ApiRole::Tool;
2723
2724 assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
2725 assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
2726 assert_eq!(
2727 serde_json::to_string(&assistant_role).unwrap(),
2728 "\"assistant\""
2729 );
2730 assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
2731 }
2732
2733 #[test]
2734 fn test_api_message_serialization_simple() {
2735 let message = ApiMessage {
2736 role: ApiRole::User,
2737 content: Some("Hello, world!".to_string()),
2738 reasoning_content: None,
2739 tool_calls: None,
2740 tool_call_id: None,
2741 prompt_cache_breakpoint: false,
2742 };
2743
2744 let json = serde_json::to_string(&message).unwrap();
2745 assert!(json.contains("\"role\":\"user\""));
2746 assert!(json.contains("\"content\":\"Hello, world!\""));
2747 assert!(!json.contains("tool_calls"));
2749 assert!(!json.contains("tool_call_id"));
2750 }
2751
2752 #[test]
2753 fn test_api_message_serialization_with_tool_calls() {
2754 let message = ApiMessage {
2755 role: ApiRole::Assistant,
2756 content: Some("Let me help.".to_string()),
2757 reasoning_content: None,
2758 tool_calls: Some(vec![ApiToolCall {
2759 id: "call_123".to_string(),
2760 r#type: "function".to_string(),
2761 function: ApiFunctionCall {
2762 name: "read_file".to_string(),
2763 arguments: "{\"path\": \"/test.txt\"}".to_string(),
2764 },
2765 }]),
2766 tool_call_id: None,
2767 prompt_cache_breakpoint: false,
2768 };
2769
2770 let json = serde_json::to_string(&message).unwrap();
2771 assert!(json.contains("\"role\":\"assistant\""));
2772 assert!(json.contains("\"tool_calls\""));
2773 assert!(json.contains("\"id\":\"call_123\""));
2774 assert!(json.contains("\"type\":\"function\""));
2775 assert!(json.contains("\"name\":\"read_file\""));
2776 }
2777
2778 #[test]
2779 fn test_api_tool_message_serialization() {
2780 let message = ApiMessage {
2781 role: ApiRole::Tool,
2782 content: Some("File contents here".to_string()),
2783 reasoning_content: None,
2784 tool_calls: None,
2785 tool_call_id: Some("call_123".to_string()),
2786 prompt_cache_breakpoint: false,
2787 };
2788
2789 let json = serde_json::to_string(&message).unwrap();
2790 assert!(json.contains("\"role\":\"tool\""));
2791 assert!(json.contains("\"tool_call_id\":\"call_123\""));
2792 assert!(json.contains("\"content\":\"File contents here\""));
2793 }
2794
2795 #[test]
2796 fn test_api_tool_serialization() {
2797 let tool = ApiTool {
2798 r#type: "function".to_string(),
2799 function: ApiFunction {
2800 name: "test_tool".to_string(),
2801 description: "A test tool".to_string(),
2802 parameters: serde_json::json!({
2803 "type": "object",
2804 "properties": {
2805 "arg": {"type": "string"}
2806 }
2807 }),
2808 strict: Some(true),
2809 },
2810 };
2811
2812 let json = serde_json::to_string(&tool).unwrap();
2813 assert!(json.contains("\"type\":\"function\""));
2814 assert!(json.contains("\"name\":\"test_tool\""));
2815 assert!(json.contains("\"description\":\"A test tool\""));
2816 assert!(json.contains("\"parameters\""));
2817 }
2818
2819 #[test]
2824 fn test_api_response_deserialization() {
2825 let json = r#"{
2826 "id": "chatcmpl-123",
2827 "choices": [
2828 {
2829 "message": {
2830 "content": "Hello!"
2831 },
2832 "finish_reason": "stop"
2833 }
2834 ],
2835 "model": "gpt-4o",
2836 "usage": {
2837 "prompt_tokens": 100,
2838 "completion_tokens": 50
2839 }
2840 }"#;
2841
2842 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2843 assert_eq!(response.id, "chatcmpl-123");
2844 assert_eq!(response.model, "gpt-4o");
2845 assert_eq!(response.usage.prompt_tokens, 100);
2846 assert_eq!(response.usage.completion_tokens, 50);
2847 assert_eq!(response.choices.len(), 1);
2848 assert_eq!(
2849 response.choices[0].message.content,
2850 Some("Hello!".to_string())
2851 );
2852 }
2853
2854 #[test]
2855 fn structured_refusal_is_visible_and_sets_refusal_stop_reason() -> anyhow::Result<()> {
2856 let body = serde_json::json!({
2857 "id": "chatcmpl-refusal",
2858 "choices": [{
2859 "message": {
2860 "content": null,
2861 "refusal": "I cannot help with that."
2862 },
2863 "finish_reason": "stop"
2864 }],
2865 "model": "gpt-5.6",
2866 "usage": {
2867 "prompt_tokens": 12,
2868 "completion_tokens": 6
2869 }
2870 });
2871 let bytes = serde_json::to_vec(&body)?;
2872 let outcome = decode_chat_response(StatusCode::OK, &bytes, None)?;
2873 let ChatOutcome::Success(response) = outcome else {
2874 anyhow::bail!("structured refusal did not decode as a successful provider response");
2875 };
2876
2877 assert_eq!(response.first_text(), Some("I cannot help with that."));
2878 assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2879 Ok(())
2880 }
2881
2882 #[test]
2883 fn non_tool_terminal_suppresses_nonstream_tool_calls() -> anyhow::Result<()> {
2884 let body = serde_json::json!({
2885 "id": "chatcmpl-truncated-tool",
2886 "choices": [{
2887 "message": {
2888 "content": "partial",
2889 "tool_calls": [{
2890 "id": "call_partial",
2891 "type": "function",
2892 "function": {
2893 "name": "delete_record",
2894 "arguments": "{\"id\":"
2895 }
2896 }]
2897 },
2898 "finish_reason": "length"
2899 }],
2900 "model": "gpt-5.6",
2901 "usage": {"prompt_tokens": 12, "completion_tokens": 6}
2902 });
2903 let bytes = serde_json::to_vec(&body)?;
2904 let ChatOutcome::Success(response) = decode_chat_response(StatusCode::OK, &bytes, None)?
2905 else {
2906 anyhow::bail!("truncated response did not decode as a successful provider response");
2907 };
2908
2909 assert_eq!(response.stop_reason, Some(StopReason::MaxTokens));
2910 assert_eq!(response.first_text(), Some("partial"));
2911 assert!(!response.has_tool_use());
2912 Ok(())
2913 }
2914
2915 #[test]
2916 fn test_api_response_with_tool_calls_deserialization() {
2917 let json = r#"{
2918 "id": "chatcmpl-456",
2919 "choices": [
2920 {
2921 "message": {
2922 "content": null,
2923 "tool_calls": [
2924 {
2925 "id": "call_abc",
2926 "type": "function",
2927 "function": {
2928 "name": "read_file",
2929 "arguments": "{\"path\": \"test.txt\"}"
2930 }
2931 }
2932 ]
2933 },
2934 "finish_reason": "tool_calls"
2935 }
2936 ],
2937 "model": "gpt-4o",
2938 "usage": {
2939 "prompt_tokens": 150,
2940 "completion_tokens": 30
2941 }
2942 }"#;
2943
2944 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2945 let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
2946 assert_eq!(tool_calls.len(), 1);
2947 assert_eq!(tool_calls[0].id, "call_abc");
2948 assert_eq!(tool_calls[0].function.name, "read_file");
2949 }
2950
2951 #[test]
2952 fn test_api_response_with_unknown_finish_reason_deserialization() {
2953 let json = r#"{
2954 "id": "chatcmpl-789",
2955 "choices": [
2956 {
2957 "message": {
2958 "content": "ok"
2959 },
2960 "finish_reason": "vendor_custom_reason"
2961 }
2962 ],
2963 "model": "glm-5",
2964 "usage": {
2965 "prompt_tokens": 10,
2966 "completion_tokens": 5
2967 }
2968 }"#;
2969
2970 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2971 assert_eq!(
2972 response.choices[0].finish_reason.as_deref(),
2973 Some("vendor_custom_reason")
2974 );
2975 assert_eq!(
2976 map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
2977 StopReason::Unknown
2978 );
2979 }
2980
2981 #[test]
2982 fn test_map_finish_reason_covers_vendor_specific_values() {
2983 assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
2984 assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
2985 assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
2986 assert_eq!(map_finish_reason("content_filter"), StopReason::Refusal);
2987 assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
2988 assert_eq!(map_finish_reason("refusal"), StopReason::Refusal);
2989 assert_eq!(map_finish_reason("network_error"), StopReason::Unknown);
2990 assert_eq!(map_finish_reason("some_new_reason"), StopReason::Unknown);
2991 }
2992
2993 #[test]
2998 fn test_build_api_messages_with_system() {
2999 let request = ChatRequest {
3000 system: "You are helpful.".to_string(),
3001 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
3002 tools: None,
3003 max_tokens: 1024,
3004 max_tokens_explicit: true,
3005 session_id: None,
3006 cached_content: None,
3007 thinking: None,
3008 tool_choice: None,
3009 response_format: None,
3010 cache: None,
3011 };
3012
3013 let api_messages = build_api_messages(&request);
3014 assert_eq!(api_messages.len(), 2);
3015 assert_eq!(api_messages[0].role, ApiRole::System);
3016 assert_eq!(
3017 api_messages[0].content,
3018 Some("You are helpful.".to_string())
3019 );
3020 assert_eq!(api_messages[1].role, ApiRole::User);
3021 assert_eq!(api_messages[1].content, Some("Hello".to_string()));
3022 }
3023
3024 #[test]
3025 fn test_build_api_messages_empty_system() {
3026 let request = ChatRequest {
3027 system: String::new(),
3028 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
3029 tools: None,
3030 max_tokens: 1024,
3031 max_tokens_explicit: true,
3032 session_id: None,
3033 cached_content: None,
3034 thinking: None,
3035 tool_choice: None,
3036 response_format: None,
3037 cache: None,
3038 };
3039
3040 let api_messages = build_api_messages(&request);
3041 assert_eq!(api_messages.len(), 1);
3042 assert_eq!(api_messages[0].role, ApiRole::User);
3043 }
3044
3045 fn request_with_messages(messages: Vec<agent_sdk_foundation::llm::Message>) -> ChatRequest {
3046 ChatRequest {
3047 system: String::new(),
3048 messages,
3049 tools: None,
3050 max_tokens: 1024,
3051 max_tokens_explicit: true,
3052 session_id: None,
3053 cached_content: None,
3054 thinking: None,
3055 tool_choice: None,
3056 response_format: None,
3057 cache: None,
3058 }
3059 }
3060
3061 #[test]
3062 fn test_build_api_messages_echoes_assistant_reasoning_content_on_tool_call()
3063 -> anyhow::Result<()> {
3064 let request = request_with_messages(vec![
3068 agent_sdk_foundation::llm::Message::user("What is the weather?"),
3069 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
3070 ContentBlock::Thinking {
3071 thinking: "I should call the weather tool.".to_string(),
3072 signature: None,
3073 },
3074 ContentBlock::ToolUse {
3075 id: "call_1".to_string(),
3076 name: "get_weather".to_string(),
3077 input: serde_json::json!({"city": "Paris"}),
3078 thought_signature: None,
3079 },
3080 ]),
3081 ]);
3082
3083 let api_messages = build_api_messages(&request);
3084 let assistant = api_messages
3085 .iter()
3086 .find(|m| m.role == ApiRole::Assistant)
3087 .context("assistant message present")?;
3088 assert!(assistant.tool_calls.is_some());
3089 assert_eq!(
3090 assistant.reasoning_content,
3091 Some("I should call the weather tool.".to_string())
3092 );
3093 Ok(())
3094 }
3095
3096 #[test]
3097 fn test_build_api_messages_reasoning_content_serializes_on_tool_call_turn() -> anyhow::Result<()>
3098 {
3099 let request = request_with_messages(vec![
3100 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
3101 ContentBlock::Thinking {
3102 thinking: "thinking out loud".to_string(),
3103 signature: None,
3104 },
3105 ContentBlock::ToolUse {
3106 id: "call_1".to_string(),
3107 name: "do_thing".to_string(),
3108 input: serde_json::json!({}),
3109 thought_signature: None,
3110 },
3111 ]),
3112 ]);
3113
3114 let api_messages = build_api_messages(&request);
3115 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3116 assert!(json.contains("\"reasoning_content\":\"thinking out loud\""));
3117 Ok(())
3118 }
3119
3120 #[test]
3121 fn test_build_api_messages_reasoning_only_turn_is_not_echoed() -> anyhow::Result<()> {
3122 let request = request_with_messages(vec![
3128 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
3129 ContentBlock::Thinking {
3130 thinking: "pondering".to_string(),
3131 signature: None,
3132 },
3133 ]),
3134 ]);
3135
3136 let api_messages = build_api_messages(&request);
3137 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3138 assert!(!json.contains("reasoning_content"));
3139 assert!(api_messages.is_empty());
3140 Ok(())
3141 }
3142
3143 #[test]
3144 fn test_build_api_messages_reasoning_with_text_no_tool_call_is_not_echoed() -> anyhow::Result<()>
3145 {
3146 let request = request_with_messages(vec![
3149 agent_sdk_foundation::llm::Message::user("What is 2+2?"),
3150 agent_sdk_foundation::llm::Message::assistant_with_content(vec![
3151 ContentBlock::Thinking {
3152 thinking: "Let me add 2 and 2.".to_string(),
3153 signature: None,
3154 },
3155 ContentBlock::Text {
3156 text: "4".to_string(),
3157 },
3158 ]),
3159 agent_sdk_foundation::llm::Message::user("And 3+3?"),
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.content, Some("4".to_string()));
3170 assert_eq!(assistant.reasoning_content, None);
3171 Ok(())
3172 }
3173
3174 #[test]
3175 fn test_build_api_messages_normal_path_has_no_reasoning_content() -> anyhow::Result<()> {
3176 let request = request_with_messages(vec![
3179 agent_sdk_foundation::llm::Message::user("hi"),
3180 agent_sdk_foundation::llm::Message::assistant_with_content(vec![ContentBlock::Text {
3181 text: "hello".to_string(),
3182 }]),
3183 ]);
3184
3185 let api_messages = build_api_messages(&request);
3186 let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3187 assert!(!json.contains("reasoning_content"));
3188 let assistant = api_messages
3189 .iter()
3190 .find(|m| m.role == ApiRole::Assistant)
3191 .context("assistant message present")?;
3192 assert_eq!(assistant.reasoning_content, None);
3193 Ok(())
3194 }
3195
3196 #[test]
3197 fn test_build_api_messages_does_not_attach_reasoning_to_user_blocks() {
3198 let request =
3201 request_with_messages(vec![agent_sdk_foundation::llm::Message::user_with_content(
3202 vec![
3203 ContentBlock::Thinking {
3204 thinking: "user-side thinking".to_string(),
3205 signature: None,
3206 },
3207 ContentBlock::Text {
3208 text: "question".to_string(),
3209 },
3210 ],
3211 )]);
3212
3213 let api_messages = build_api_messages(&request);
3214 assert_eq!(api_messages.len(), 1);
3215 assert_eq!(api_messages[0].role, ApiRole::User);
3216 assert_eq!(api_messages[0].reasoning_content, None);
3217 }
3218
3219 #[test]
3220 fn test_convert_tool() {
3221 let tool = agent_sdk_foundation::llm::Tool {
3222 name: "test_tool".to_string(),
3223 description: "A test tool".to_string(),
3224 input_schema: serde_json::json!({
3225 "type": "object",
3226 "properties": {"value": {"type": "string"}}
3227 }),
3228 display_name: "Test Tool".to_string(),
3229 tier: agent_sdk_foundation::ToolTier::Observe,
3230 };
3231
3232 let compatible_tool = convert_tool(tool.clone(), false);
3233 assert!(compatible_tool.function.strict.is_none());
3234 assert_eq!(
3235 compatible_tool.function.parameters,
3236 serde_json::json!({
3237 "type": "object",
3238 "properties": {"value": {"type": "string"}}
3239 })
3240 );
3241
3242 let api_tool = convert_tool(tool, true);
3243 assert_eq!(api_tool.r#type, "function");
3244 assert_eq!(api_tool.function.name, "test_tool");
3245 assert_eq!(api_tool.function.description, "A test tool");
3246 assert_eq!(api_tool.function.strict, Some(true));
3247 assert_eq!(api_tool.function.parameters["additionalProperties"], false);
3248 }
3249
3250 #[test]
3251 fn test_build_content_blocks_text_only() {
3252 let message = ApiResponseMessage {
3253 content: Some("Hello!".to_string()),
3254 refusal: None,
3255 tool_calls: None,
3256 reasoning_content: None,
3257 reasoning: None,
3258 };
3259
3260 let blocks = build_content_blocks(&message);
3261 assert_eq!(blocks.len(), 1);
3262 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
3263 }
3264
3265 #[test]
3266 fn test_build_content_blocks_with_tool_calls() {
3267 let message = ApiResponseMessage {
3268 content: Some("Let me help.".to_string()),
3269 refusal: None,
3270 tool_calls: Some(vec![ApiResponseToolCall {
3271 id: "call_123".to_string(),
3272 function: ApiResponseFunctionCall {
3273 name: "read_file".to_string(),
3274 arguments: "{\"path\": \"test.txt\"}".to_string(),
3275 },
3276 }]),
3277 reasoning_content: None,
3278 reasoning: None,
3279 };
3280
3281 let blocks = build_content_blocks(&message);
3282 assert_eq!(blocks.len(), 2);
3283 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
3284 assert!(
3285 matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
3286 );
3287 }
3288
3289 #[test]
3290 fn test_build_content_blocks_falls_back_to_reasoning_content_when_content_empty() {
3291 let message = ApiResponseMessage {
3294 content: None,
3295 refusal: None,
3296 tool_calls: None,
3297 reasoning_content: Some("The answer is 42.".to_string()),
3298 reasoning: None,
3299 };
3300
3301 let blocks = build_content_blocks(&message);
3302 assert_eq!(blocks.len(), 1);
3303 assert!(
3304 matches!(&blocks[0], ContentBlock::Thinking { thinking, signature } if thinking == "The answer is 42." && signature.is_none())
3305 );
3306 }
3307
3308 #[test]
3309 fn test_build_content_blocks_falls_back_to_reasoning_field() {
3310 let message = ApiResponseMessage {
3312 content: Some(String::new()),
3313 refusal: None,
3314 tool_calls: None,
3315 reasoning_content: None,
3316 reasoning: Some("Considering options...".to_string()),
3317 };
3318
3319 let blocks = build_content_blocks(&message);
3320 assert_eq!(blocks.len(), 1);
3321 assert!(
3322 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "Considering options...")
3323 );
3324 }
3325
3326 #[test]
3327 fn test_build_content_blocks_prefers_reasoning_content_over_reasoning() {
3328 let message = ApiResponseMessage {
3329 content: None,
3330 refusal: None,
3331 tool_calls: None,
3332 reasoning_content: Some("primary".to_string()),
3333 reasoning: Some("secondary".to_string()),
3334 };
3335
3336 let blocks = build_content_blocks(&message);
3337 assert_eq!(blocks.len(), 1);
3338 assert!(
3339 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "primary")
3340 );
3341 }
3342
3343 #[test]
3344 fn test_build_content_blocks_does_not_add_reasoning_when_content_present() {
3345 let message = ApiResponseMessage {
3348 content: Some("Final answer.".to_string()),
3349 refusal: None,
3350 tool_calls: None,
3351 reasoning_content: Some("internal chain of thought".to_string()),
3352 reasoning: None,
3353 };
3354
3355 let blocks = build_content_blocks(&message);
3356 assert_eq!(blocks.len(), 1);
3357 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Final answer."));
3358 }
3359
3360 #[test]
3361 fn test_build_content_blocks_reasoning_fallback_with_tool_calls() {
3362 let message = ApiResponseMessage {
3365 content: None,
3366 refusal: None,
3367 tool_calls: Some(vec![ApiResponseToolCall {
3368 id: "call_1".to_string(),
3369 function: ApiResponseFunctionCall {
3370 name: "search".to_string(),
3371 arguments: "{}".to_string(),
3372 },
3373 }]),
3374 reasoning_content: Some("I should search.".to_string()),
3375 reasoning: None,
3376 };
3377
3378 let blocks = build_content_blocks(&message);
3379 assert_eq!(blocks.len(), 2);
3380 assert!(
3381 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "I should search.")
3382 );
3383 assert!(matches!(&blocks[1], ContentBlock::ToolUse { name, .. } if name == "search"));
3384 }
3385
3386 #[test]
3387 fn test_build_content_blocks_empty_message_yields_no_blocks() {
3388 let message = ApiResponseMessage {
3391 content: None,
3392 refusal: None,
3393 tool_calls: None,
3394 reasoning_content: None,
3395 reasoning: None,
3396 };
3397
3398 let blocks = build_content_blocks(&message);
3399 assert!(blocks.is_empty());
3400 }
3401
3402 #[test]
3403 fn test_api_response_message_deserializes_reasoning_content() {
3404 let json = r#"{
3405 "content": null,
3406 "reasoning_content": "step by step"
3407 }"#;
3408
3409 let message: ApiResponseMessage = serde_json::from_str(json).unwrap();
3410 assert_eq!(reasoning_text(&message), Some("step by step"));
3411 assert!(message.content.is_none());
3412 }
3413
3414 #[test]
3419 fn test_sse_chunk_text_delta_deserialization() {
3420 let json = r#"{
3421 "choices": [{
3422 "delta": {
3423 "content": "Hello"
3424 },
3425 "finish_reason": null
3426 }]
3427 }"#;
3428
3429 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3430 assert_eq!(chunk.choices.len(), 1);
3431 assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
3432 assert!(chunk.choices[0].finish_reason.is_none());
3433 }
3434
3435 #[test]
3436 fn test_sse_chunk_tool_call_delta_deserialization() {
3437 let json = r#"{
3438 "choices": [{
3439 "delta": {
3440 "tool_calls": [{
3441 "index": 0,
3442 "id": "call_abc",
3443 "function": {
3444 "name": "read_file",
3445 "arguments": ""
3446 }
3447 }]
3448 },
3449 "finish_reason": null
3450 }]
3451 }"#;
3452
3453 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3454 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
3455 assert_eq!(tool_calls.len(), 1);
3456 assert_eq!(tool_calls[0].index, 0);
3457 assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
3458 assert_eq!(
3459 tool_calls[0].function.as_ref().unwrap().name,
3460 Some("read_file".to_string())
3461 );
3462 }
3463
3464 #[test]
3465 fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
3466 let json = r#"{
3467 "choices": [{
3468 "delta": {
3469 "tool_calls": [{
3470 "index": 0,
3471 "function": {
3472 "arguments": "{\"path\":"
3473 }
3474 }]
3475 },
3476 "finish_reason": null
3477 }]
3478 }"#;
3479
3480 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3481 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
3482 assert_eq!(tool_calls[0].id, None);
3483 assert_eq!(
3484 tool_calls[0].function.as_ref().unwrap().arguments,
3485 Some("{\"path\":".to_string())
3486 );
3487 }
3488
3489 #[test]
3490 fn test_sse_chunk_with_finish_reason_deserialization() {
3491 let json = r#"{
3492 "choices": [{
3493 "delta": {},
3494 "finish_reason": "stop"
3495 }]
3496 }"#;
3497
3498 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3499 assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
3500 }
3501
3502 #[test]
3503 fn test_sse_chunk_with_usage_deserialization() {
3504 let json = r#"{
3505 "choices": [{
3506 "delta": {},
3507 "finish_reason": "stop"
3508 }],
3509 "usage": {
3510 "prompt_tokens": 100,
3511 "completion_tokens": 50
3512 }
3513 }"#;
3514
3515 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3516 let usage = chunk.usage.unwrap();
3517 assert_eq!(usage.prompt_tokens, 100);
3518 assert_eq!(usage.completion_tokens, 50);
3519 }
3520
3521 #[test]
3522 fn test_sse_chunk_with_float_usage_deserialization() {
3523 let json = r#"{
3524 "choices": [{
3525 "delta": {},
3526 "finish_reason": "stop"
3527 }],
3528 "usage": {
3529 "prompt_tokens": 100.0,
3530 "completion_tokens": 50.0
3531 }
3532 }"#;
3533
3534 let chunk: SseChunk = serde_json::from_str(json).unwrap();
3535 let usage = chunk.usage.unwrap();
3536 assert_eq!(usage.prompt_tokens, 100);
3537 assert_eq!(usage.completion_tokens, 50);
3538 }
3539
3540 #[test]
3541 fn test_api_usage_deserializes_integer_compatible_numbers() {
3542 let json = r#"{
3543 "prompt_tokens": 42.0,
3544 "completion_tokens": 7
3545 }"#;
3546
3547 let usage: ApiUsage = serde_json::from_str(json).unwrap();
3548 assert_eq!(usage.prompt_tokens, 42);
3549 assert_eq!(usage.completion_tokens, 7);
3550 }
3551
3552 #[test]
3553 fn test_process_sse_data_maps_in_band_rate_limit_error_chunk() -> anyhow::Result<()> {
3554 let results = process_sse_data(
3557 r#"{"id":"gen-1","choices":[{"delta":{},"finish_reason":"error","index":0}],"error":{"code":429,"message":"Rate limited by upstream. Please try again in 12s."}}"#,
3558 );
3559
3560 let kind = results
3561 .iter()
3562 .find_map(|result| match result {
3563 SseProcessResult::Error { kind, .. } => Some(*kind),
3564 _ => None,
3565 })
3566 .context("expected an in-band error result")?;
3567
3568 assert_eq!(
3569 kind,
3570 StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(12))),
3571 "an in-band 429 must be retriable and keep its parsed delay"
3572 );
3573 assert!(kind.is_recoverable());
3574 Ok(())
3575 }
3576
3577 #[test]
3578 fn test_process_sse_data_keeps_usage_carried_by_an_error_chunk() -> anyhow::Result<()> {
3579 let results = process_sse_data(
3583 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."}}"#,
3584 );
3585
3586 let usage_at = results
3587 .iter()
3588 .position(|result| matches!(result, SseProcessResult::Usage(_)))
3589 .context("the error chunk's usage must not be dropped")?;
3590 let error_at = results
3591 .iter()
3592 .position(|result| matches!(result, SseProcessResult::Error { .. }))
3593 .context("expected an in-band error result")?;
3594 assert!(
3595 usage_at < error_at,
3596 "usage must be emitted before the terminal error"
3597 );
3598
3599 let usage = results
3600 .iter()
3601 .find_map(|result| match result {
3602 SseProcessResult::Usage(usage) => Some(usage),
3603 _ => None,
3604 })
3605 .context("expected a usage result")?;
3606 assert_eq!(usage.input_tokens, 140);
3607 assert_eq!(usage.output_tokens, 20);
3608
3609 let mut tool_calls = HashMap::new();
3611 let mut stream_usage = None;
3612 let mut stop_reason = None;
3613 let outcome = step_completion_stream(
3614 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."}}"#,
3615 &mut tool_calls,
3616 &mut stream_usage,
3617 &mut stop_reason,
3618 );
3619 let terminal = outcome.terminal.context("the error must be terminal")?;
3620 assert!(
3621 matches!(terminal.first(), Some(StreamDelta::Usage(usage)) if usage.input_tokens == 140),
3622 "the usage delta must lead the terminal sequence, got {terminal:?}"
3623 );
3624 assert!(matches!(
3625 terminal.last(),
3626 Some(StreamDelta::Error {
3627 kind: StreamErrorKind::RateLimited(Some(_)),
3628 ..
3629 })
3630 ));
3631 Ok(())
3632 }
3633
3634 #[test]
3635 fn test_process_sse_data_maps_in_band_symbolic_and_server_error_chunks() -> anyhow::Result<()> {
3636 let symbolic = process_sse_data(
3638 r#"{"choices":[],"error":{"code":"rate_limit_exceeded","message":"Please try again in 250ms."}}"#,
3639 );
3640 let kind = symbolic
3641 .iter()
3642 .find_map(|result| match result {
3643 SseProcessResult::Error { kind, .. } => Some(*kind),
3644 _ => None,
3645 })
3646 .context("expected an in-band error result")?;
3647 assert_eq!(
3648 kind,
3649 StreamErrorKind::RateLimited(Some(std::time::Duration::from_millis(250)))
3650 );
3651
3652 let server = process_sse_data(
3654 r#"{"choices":[],"error":{"code":502,"message":"upstream unavailable"}}"#,
3655 );
3656 let server_kind = server
3657 .iter()
3658 .find_map(|result| match result {
3659 SseProcessResult::Error { kind, .. } => Some(*kind),
3660 _ => None,
3661 })
3662 .context("expected an in-band error result")?;
3663 assert_eq!(server_kind, StreamErrorKind::ServerError);
3664
3665 let invalid =
3666 process_sse_data(r#"{"choices":[],"error":{"code":400,"message":"bad request"}}"#);
3667 let invalid_kind = invalid
3668 .iter()
3669 .find_map(|result| match result {
3670 SseProcessResult::Error { kind, .. } => Some(*kind),
3671 _ => None,
3672 })
3673 .context("expected an in-band error result")?;
3674 assert_eq!(invalid_kind, StreamErrorKind::InvalidRequest);
3675 assert!(!invalid_kind.is_recoverable());
3676 Ok(())
3677 }
3678
3679 #[test]
3680 fn test_api_usage_deserializes_cache_read_and_write_tokens() -> anyhow::Result<()> {
3681 let json = r#"{
3682 "prompt_tokens": 42,
3683 "completion_tokens": 7,
3684 "prompt_tokens_details": {
3685 "cached_tokens": 10,
3686 "cache_write_tokens": 6
3687 }
3688 }"#;
3689
3690 let usage: ApiUsage = serde_json::from_str(json)?;
3691 assert_eq!(usage.prompt_tokens, 42);
3692 assert_eq!(usage.completion_tokens, 7);
3693 let details = usage
3694 .prompt_tokens_details
3695 .context("prompt token details missing")?;
3696 assert_eq!(details.cached_tokens, 10);
3697 assert_eq!(details.cache_write_tokens, 6);
3698 Ok(())
3699 }
3700
3701 #[test]
3702 fn test_process_sse_data_maps_cache_read_and_write_usage() {
3703 let results = process_sse_data(
3704 r#"{
3705 "choices": [],
3706 "usage": {
3707 "prompt_tokens": 42,
3708 "completion_tokens": 7,
3709 "prompt_tokens_details": {
3710 "cached_tokens": 10,
3711 "cache_write_tokens": 6
3712 }
3713 }
3714 }"#,
3715 );
3716
3717 assert!(matches!(
3718 results.as_slice(),
3719 [SseProcessResult::Usage(Usage {
3720 input_tokens: 42,
3721 output_tokens: 7,
3722 cached_input_tokens: 10,
3723 cache_creation_input_tokens: 6,
3724 })]
3725 ));
3726 }
3727
3728 #[test]
3729 fn test_sse_delta_deserializes_reasoning_fields() -> anyhow::Result<()> {
3730 let chunk: SseChunk = serde_json::from_str(
3734 r#"{
3735 "choices": [{
3736 "delta": {
3737 "reasoning_content": "step one"
3738 },
3739 "finish_reason": null
3740 }]
3741 }"#,
3742 )
3743 .context("deserialize sse chunk")?;
3744 assert_eq!(
3745 chunk.choices[0].delta.reasoning_content,
3746 Some("step one".to_string())
3747 );
3748 assert!(chunk.choices[0].delta.content.is_none());
3749 Ok(())
3750 }
3751
3752 #[test]
3753 fn test_process_sse_data_emits_thinking_delta_from_reasoning_content() {
3754 let results = process_sse_data(
3759 r#"{
3760 "choices": [{
3761 "delta": { "reasoning_content": "thinking..." },
3762 "finish_reason": null
3763 }]
3764 }"#,
3765 );
3766
3767 assert!(matches!(
3768 results.as_slice(),
3769 [SseProcessResult::ThinkingDelta(text)] if text == "thinking..."
3770 ));
3771 }
3772
3773 #[test]
3774 fn test_process_sse_data_emits_thinking_delta_from_reasoning_field() {
3775 let results = process_sse_data(
3777 r#"{
3778 "choices": [{
3779 "delta": { "reasoning": "pondering" },
3780 "finish_reason": null
3781 }]
3782 }"#,
3783 );
3784
3785 assert!(matches!(
3786 results.as_slice(),
3787 [SseProcessResult::ThinkingDelta(text)] if text == "pondering"
3788 ));
3789 }
3790
3791 #[test]
3792 fn test_process_sse_data_prefers_text_content_over_reasoning() {
3793 let results = process_sse_data(
3796 r#"{
3797 "choices": [{
3798 "delta": {
3799 "content": "answer",
3800 "reasoning_content": "ignored"
3801 },
3802 "finish_reason": null
3803 }]
3804 }"#,
3805 );
3806
3807 assert!(matches!(
3808 results.as_slice(),
3809 [SseProcessResult::TextDelta(text)] if text == "answer"
3810 ));
3811 }
3812
3813 #[test]
3814 fn test_process_sse_data_empty_content_falls_back_to_reasoning() {
3815 let results = process_sse_data(
3818 r#"{
3819 "choices": [{
3820 "delta": {
3821 "content": "",
3822 "reasoning_content": "fallback"
3823 },
3824 "finish_reason": null
3825 }]
3826 }"#,
3827 );
3828
3829 assert!(matches!(
3830 results.as_slice(),
3831 [SseProcessResult::ThinkingDelta(text)] if text == "fallback"
3832 ));
3833 }
3834
3835 #[test]
3836 fn streamed_refusal_is_visible_and_preserves_refusal_stop_reason() -> anyhow::Result<()> {
3837 let mut tool_calls = HashMap::new();
3838 let mut usage = None;
3839 let mut stop_reason = None;
3840 let chunk = step_completion_stream(
3841 r#"{"choices":[{"delta":{"refusal":"Cannot comply."},"finish_reason":"stop"}]}"#,
3842 &mut tool_calls,
3843 &mut usage,
3844 &mut stop_reason,
3845 );
3846
3847 assert!(matches!(
3848 chunk.immediate.as_slice(),
3849 [StreamDelta::TextDelta { delta, .. }] if delta == "Cannot comply."
3850 ));
3851 assert_eq!(stop_reason, Some(StopReason::Refusal));
3852
3853 let done = step_completion_stream("[DONE]", &mut tool_calls, &mut usage, &mut stop_reason)
3854 .terminal
3855 .context("[DONE] did not finalize refusal stream")?;
3856 assert!(done.iter().any(|delta| matches!(
3857 delta,
3858 StreamDelta::Done {
3859 stop_reason: Some(StopReason::Refusal)
3860 }
3861 )));
3862 Ok(())
3863 }
3864
3865 #[test]
3866 fn test_api_usage_rejects_fractional_numbers() {
3867 let json = r#"{
3868 "prompt_tokens": 42.5,
3869 "completion_tokens": 7
3870 }"#;
3871
3872 let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
3873 assert!(usage.is_err());
3874 }
3875
3876 #[test]
3877 fn test_use_max_tokens_alias_for_vendor_urls() {
3878 assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
3879 assert!(use_max_tokens_alias(BASE_URL_KIMI));
3880 assert!(use_max_tokens_alias(BASE_URL_ZAI));
3881 assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
3882 }
3883
3884 #[test]
3885 fn test_requires_responses_api_for_codex_models() {
3886 assert!(requires_responses_api(MODEL_GPT52_CODEX));
3887 assert!(requires_responses_api(MODEL_GPT52_PRO));
3888 assert!(requires_responses_api(MODEL_GPT53_CODEX));
3889 assert!(!requires_responses_api(MODEL_GPT54));
3890 assert!(!requires_responses_api(MODEL_GPT56));
3891 assert!(!requires_responses_api(MODEL_GPT56_SOL));
3892 assert!(!requires_responses_api(MODEL_GPT56_TERRA));
3893 assert!(!requires_responses_api(MODEL_GPT56_LUNA));
3894 }
3895
3896 #[test]
3897 fn test_should_use_responses_api_for_official_agentic_requests() {
3898 let request = ChatRequest {
3899 system: String::new(),
3900 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
3901 tools: Some(vec![agent_sdk_foundation::llm::Tool {
3902 name: "read_file".to_string(),
3903 description: "Read a file".to_string(),
3904 input_schema: serde_json::json!({"type": "object"}),
3905 display_name: "Read File".to_string(),
3906 tier: agent_sdk_foundation::ToolTier::Observe,
3907 }]),
3908 max_tokens: 1024,
3909 max_tokens_explicit: true,
3910 session_id: Some("thread-1".to_string()),
3911 cached_content: None,
3912 thinking: None,
3913 tool_choice: None,
3914 response_format: None,
3915 cache: None,
3916 };
3917
3918 for model in [
3919 MODEL_GPT54,
3920 MODEL_GPT56,
3921 MODEL_GPT56_SOL,
3922 MODEL_GPT56_TERRA,
3923 MODEL_GPT56_LUNA,
3924 ] {
3925 assert!(should_use_responses_api(
3926 DEFAULT_BASE_URL,
3927 model,
3928 &request,
3929 None
3930 ));
3931 }
3932 assert!(!should_use_responses_api(
3933 BASE_URL_KIMI,
3934 MODEL_GPT54,
3935 &request,
3936 None,
3937 ));
3938 }
3939
3940 #[test]
3941 fn official_gpt56_auto_routes_but_custom_base_url_does_not() {
3942 let request = ChatRequest::new(String::new(), vec![]);
3943 assert!(should_use_responses_api(
3944 DEFAULT_BASE_URL,
3945 MODEL_GPT56,
3946 &request,
3947 None,
3948 ));
3949 assert!(!should_use_responses_api(
3950 "https://gateway.example/v1",
3951 MODEL_GPT56,
3952 &request,
3953 None,
3954 ));
3955 }
3956
3957 #[test]
3958 fn response_only_reasoning_controls_trigger_reroute() {
3959 let request = ChatRequest::new(String::new(), vec![]);
3960 let reasoning = OpenAIReasoningConfig::new()
3961 .with_mode(super::super::openai_reasoning::OpenAIReasoningMode::Pro);
3962 assert!(should_use_responses_api(
3963 "https://gateway.example/v1",
3964 MODEL_GPT56,
3965 &request,
3966 Some(&reasoning),
3967 ));
3968
3969 let forced_chat = reasoning.with_api_surface(OpenAIApiSurface::ChatCompletions);
3970 assert!(!should_use_responses_api(
3971 "https://gateway.example/v1",
3972 MODEL_GPT56,
3973 &request,
3974 Some(&forced_chat),
3975 ));
3976 assert!(OpenAIProvider::validate_chat_reasoning_controls(Some(&forced_chat)).is_err());
3977
3978 let codex = OpenAIProvider::gpt53_codex("test-key".to_owned()).with_reasoning(
3979 OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions),
3980 );
3981 assert!(codex.validate_requested_api_surface().is_err());
3982 }
3983
3984 #[test]
3985 fn openai_responses_history_reroutes_auto_and_forced_chat_rejects() {
3986 let request = ChatRequest::new(
3987 String::new(),
3988 vec![agent_sdk_foundation::llm::Message::assistant_with_content(
3989 vec![ContentBlock::OpaqueReasoning {
3990 provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
3991 data: serde_json::json!({
3992 "type": "reasoning",
3993 "id": "rs_1",
3994 "encrypted_content": "ciphertext"
3995 }),
3996 }],
3997 )],
3998 );
3999
4000 assert!(should_use_responses_api(
4001 "https://gateway.example/v1",
4002 MODEL_GPT54,
4003 &request,
4004 None,
4005 ));
4006
4007 let forced_chat =
4008 OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions);
4009 assert!(!should_use_responses_api(
4010 "https://gateway.example/v1",
4011 MODEL_GPT54,
4012 &request,
4013 Some(&forced_chat),
4014 ));
4015 assert!(OpenAIProvider::validate_chat_opaque_reasoning(&request).is_err());
4016 }
4017
4018 #[test]
4019 fn exact_chat_tool_choice_maps_and_validates() -> anyhow::Result<()> {
4020 let tools = vec![function_tool("read_file")];
4021
4022 let required = OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::Required);
4023 let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&required), Some(&tools))?
4024 .context("required tool choice was omitted")?;
4025 assert_eq!(serde_json::to_value(choice)?, serde_json::json!("required"));
4026
4027 let forced = OpenAIReasoningConfig::new()
4028 .with_tool_choice(OpenAIToolChoice::Function("read_file".to_owned()));
4029 let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&forced), Some(&tools))?
4030 .context("forced tool choice was omitted")?;
4031 assert_eq!(
4032 serde_json::to_value(choice)?,
4033 serde_json::json!({"type": "function", "function": {"name": "read_file"}})
4034 );
4035
4036 let allowed =
4037 OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::AllowedTools {
4038 mode: OpenAIAllowedToolsMode::Required,
4039 tools: vec!["read_file".to_owned()],
4040 });
4041 let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&allowed), Some(&tools))?
4042 .context("allowed-tools choice was omitted")?;
4043 assert_eq!(
4044 serde_json::to_value(choice)?,
4045 serde_json::json!({
4046 "type": "allowed_tools",
4047 "allowed_tools": {
4048 "mode": "required",
4049 "tools": [
4050 {"type": "function", "function": {"name": "read_file"}}
4051 ]
4052 }
4053 })
4054 );
4055
4056 let missing = OpenAIReasoningConfig::new()
4057 .with_tool_choice(OpenAIToolChoice::Function("missing".to_owned()));
4058 assert!(
4059 OpenAIProvider::resolve_chat_tool_choice(None, Some(&missing), Some(&tools)).is_err()
4060 );
4061 assert!(OpenAIProvider::resolve_chat_tool_choice(None, Some(&required), None).is_err());
4062 Ok(())
4063 }
4064
4065 #[test]
4066 fn generic_tool_choice_overrides_provider_exact_choice() -> anyhow::Result<()> {
4067 let tools = vec![function_tool("read_file")];
4068 let request_choice = ToolChoice::Tool("read_file".to_owned());
4069 let provider_choice =
4070 OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::AllowedTools {
4071 mode: super::super::openai_reasoning::OpenAIAllowedToolsMode::Required,
4072 tools: vec!["missing".to_owned()],
4073 });
4074 let request = ChatRequest::new(String::new(), vec![])
4075 .with_tools(tools.clone())
4076 .with_tool_choice(request_choice.clone());
4077
4078 assert!(!should_use_responses_api(
4079 "https://gateway.example/v1",
4080 MODEL_GPT54,
4081 &request,
4082 Some(&provider_choice),
4083 ));
4084 let choice = OpenAIProvider::resolve_chat_tool_choice(
4085 Some(&request_choice),
4086 Some(&provider_choice),
4087 Some(&tools),
4088 )?
4089 .context("generic tool choice was omitted")?;
4090 assert_eq!(
4091 serde_json::to_value(choice)?,
4092 serde_json::json!({"type": "function", "function": {"name": "read_file"}})
4093 );
4094 Ok(())
4095 }
4096
4097 #[test]
4098 fn generic_cache_control_overrides_exact_defaults_and_rejects_unmappable_ttl()
4099 -> anyhow::Result<()> {
4100 use agent_sdk_foundation::llm::{CacheConfig, CacheTtl};
4101
4102 let provider = OpenAIProvider::gpt56("test-key".to_owned());
4103 let exact = OpenAIReasoningConfig::new()
4104 .with_prompt_cache_mode(OpenAIPromptCacheMode::Explicit)
4105 .with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes);
4106
4107 let inherited_plan = provider.resolve_chat_prompt_cache_options(
4108 &ChatRequest::new(String::new(), vec![]),
4109 Some(&exact),
4110 )?;
4111 assert_eq!(inherited_plan.explicit_breakpoints, 0);
4112 let inherited = inherited_plan
4113 .options
4114 .context("exact prompt-cache options were omitted")?;
4115 assert_eq!(
4116 serde_json::to_value(inherited)?,
4117 serde_json::json!({"mode": "explicit", "ttl": "30m"})
4118 );
4119
4120 let enabled = ChatRequest::new(String::new(), vec![]).with_cache(CacheConfig::enabled());
4121 let enabled_plan = provider.resolve_chat_prompt_cache_options(&enabled, Some(&exact))?;
4122 assert_eq!(enabled_plan.explicit_breakpoints, 0);
4123 let enabled = enabled_plan
4124 .options
4125 .context("generic enabled cache options were omitted")?;
4126 assert_eq!(
4127 serde_json::to_value(enabled)?,
4128 serde_json::json!({"mode": "explicit", "ttl": "30m"})
4129 );
4130
4131 let disabled = ChatRequest::new(String::new(), vec![]).with_cache(CacheConfig::disabled());
4132 let disabled_plan = provider.resolve_chat_prompt_cache_options(&disabled, Some(&exact))?;
4133 assert_eq!(disabled_plan.explicit_breakpoints, 0);
4134 let disabled = disabled_plan
4135 .options
4136 .context("generic disabled cache options were omitted")?;
4137 assert_eq!(
4138 serde_json::to_value(disabled)?,
4139 serde_json::json!({"mode": "explicit"})
4140 );
4141
4142 let zero_breakpoints = ChatRequest::new(String::new(), vec![])
4143 .with_cache(CacheConfig::enabled().with_max_breakpoints(0));
4144 let zero_breakpoints_plan =
4145 provider.resolve_chat_prompt_cache_options(&zero_breakpoints, Some(&exact))?;
4146 assert_eq!(zero_breakpoints_plan.explicit_breakpoints, 0);
4147 let zero_breakpoints = zero_breakpoints_plan
4148 .options
4149 .context("zero-breakpoint cache options were omitted")?;
4150 assert_eq!(
4151 serde_json::to_value(zero_breakpoints)?,
4152 serde_json::json!({"mode": "explicit", "ttl": "30m"})
4153 );
4154
4155 let two_breakpoints = ChatRequest::new(String::new(), vec![])
4156 .with_cache(CacheConfig::enabled().with_max_breakpoints(2));
4157 let two_breakpoints =
4158 provider.resolve_chat_prompt_cache_options(&two_breakpoints, Some(&exact))?;
4159 assert_eq!(two_breakpoints.explicit_breakpoints, 2);
4160
4161 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
4162 for cache in [
4163 CacheConfig::enabled().with_ttl(ttl),
4164 CacheConfig::disabled().with_ttl(ttl),
4165 ] {
4166 let request = ChatRequest::new(String::new(), vec![]).with_cache(cache);
4167 let error = provider
4168 .resolve_chat_prompt_cache_options(&request, Some(&exact))
4169 .err()
4170 .context("shared cache TTL was silently accepted")?;
4171 assert!(error.to_string().contains("supports only a 30m TTL"));
4172 }
4173 }
4174 Ok(())
4175 }
4176
4177 #[test]
4178 fn chat_cache_breakpoints_prioritize_system_then_conversation_tail() -> anyhow::Result<()> {
4179 let request = ChatRequest::new(
4180 "system",
4181 vec![
4182 agent_sdk_foundation::llm::Message::user("one"),
4183 agent_sdk_foundation::llm::Message::assistant("two"),
4184 agent_sdk_foundation::llm::Message::user("three"),
4185 agent_sdk_foundation::llm::Message::assistant("four"),
4186 agent_sdk_foundation::llm::Message::user("five"),
4187 ],
4188 );
4189 let json = serde_json::to_value(build_api_messages_with_cache(&request, 9))?;
4190 let messages = json.as_array().context("messages must be an array")?;
4191 let marked = messages
4192 .iter()
4193 .filter(|message| message["content"].is_array())
4194 .count();
4195 assert_eq!(marked, 4);
4196 assert_eq!(messages[0]["content"][0]["text"], "system");
4197 assert_eq!(
4198 messages[0]["content"][0]["prompt_cache_breakpoint"]["mode"],
4199 "explicit"
4200 );
4201 assert_eq!(messages[1]["content"], "one");
4202 assert_eq!(messages[2]["content"], "two");
4203 assert_eq!(messages[5]["content"][0]["text"], "five");
4204 assert_eq!(
4205 messages[5]["content"][0]["prompt_cache_breakpoint"]["mode"],
4206 "explicit"
4207 );
4208
4209 let one_breakpoint = serde_json::to_value(build_api_messages_with_cache(&request, 1))?;
4210 let messages = one_breakpoint
4211 .as_array()
4212 .context("messages must be an array")?;
4213 assert!(messages[0]["content"].is_array());
4214 assert!(messages[5]["content"].is_string());
4215 Ok(())
4216 }
4217
4218 #[test]
4219 fn chat_strict_response_format_rejects_free_form_objects() -> anyhow::Result<()> {
4220 let format = agent_sdk_foundation::llm::ResponseFormat::new(
4221 "freeform",
4222 serde_json::json!({"type": "object"}),
4223 );
4224 assert!(
4225 OpenAIProvider::gpt54("test-key".to_owned())
4226 .validate_chat_response_format(Some(&format))
4227 .is_err()
4228 );
4229 assert!(
4230 OpenAIProvider::with_base_url("test-key", MODEL_GPT54, "https://gateway.example/v1",)
4231 .validate_chat_response_format(Some(&format))
4232 .is_ok()
4233 );
4234
4235 let structured = agent_sdk_foundation::llm::ResponseFormat::new(
4236 "structured",
4237 serde_json::json!({
4238 "type": "object",
4239 "properties": {"optional_name": {"type": "string"}}
4240 }),
4241 );
4242 let compatible = ApiResponseFormat::from_response_format(&structured, false);
4243 let compatible_json = serde_json::to_value(compatible)?;
4244 assert!(
4245 compatible_json["json_schema"]["schema"]
4246 .get("required")
4247 .is_none()
4248 );
4249
4250 let official = ApiResponseFormat::from_response_format(&structured, true);
4251 let official_json = serde_json::to_value(official)?;
4252 assert_eq!(
4253 official_json["json_schema"]["schema"]["required"],
4254 serde_json::json!(["optional_name"])
4255 );
4256 Ok(())
4257 }
4258
4259 #[test]
4260 fn forced_or_custom_chat_rejects_unserialized_attachments() {
4261 let request = ChatRequest::new(
4262 String::new(),
4263 vec![agent_sdk_foundation::llm::Message::user_with_content(vec![
4264 ContentBlock::Image {
4265 source: agent_sdk_foundation::llm::ContentSource::new("image/png", "aGVsbG8="),
4266 },
4267 ])],
4268 );
4269 let forced_chat =
4270 OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions);
4271
4272 assert!(!should_use_responses_api(
4273 DEFAULT_BASE_URL,
4274 MODEL_GPT54,
4275 &request,
4276 Some(&forced_chat),
4277 ));
4278 assert!(
4279 OpenAIProvider::gpt54("test-key".to_owned())
4280 .with_reasoning(forced_chat)
4281 .validate_chat_attachments(&request)
4282 .is_err()
4283 );
4284 assert!(
4285 OpenAIProvider::with_base_url("test-key", MODEL_GPT54, "https://gateway.example/v1",)
4286 .validate_chat_attachments(&request)
4287 .is_err()
4288 );
4289 assert!(should_use_responses_api(
4290 DEFAULT_BASE_URL,
4291 MODEL_GPT54,
4292 &request,
4293 None,
4294 ));
4295 }
4296
4297 #[test]
4298 fn legacy_budget_and_effort_mapping_remains_compatible() {
4299 assert_eq!(
4300 legacy_reasoning_effort(&ThinkingConfig::new(40_000)),
4301 Some(OpenAIReasoningEffort::XHigh)
4302 );
4303 assert_eq!(
4304 legacy_reasoning_effort(&ThinkingConfig::adaptive_with_effort(
4305 agent_sdk_foundation::llm::Effort::High,
4306 )),
4307 Some(OpenAIReasoningEffort::High)
4308 );
4309 assert_eq!(legacy_reasoning_effort(&ThinkingConfig::adaptive()), None);
4310 }
4311
4312 #[test]
4313 fn effective_max_tokens_uses_model_default_unless_explicit() {
4314 let provider = OpenAIProvider::gpt56("test-key".to_string());
4315 let implicit = ChatRequest::new(String::new(), vec![]);
4316 assert_eq!(provider.effective_max_tokens(&implicit), 128_000);
4317
4318 let explicit = implicit.with_max_tokens(8_192);
4319 assert_eq!(provider.effective_max_tokens(&explicit), 8_192);
4320 }
4321
4322 #[test]
4323 fn test_openai_rejects_adaptive_thinking() {
4324 let provider = OpenAIProvider::gpt54("test-key".to_string());
4325 let error = provider
4326 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
4327 .unwrap_err();
4328 assert!(
4329 error
4330 .to_string()
4331 .contains("adaptive thinking is not supported")
4332 );
4333 }
4334
4335 #[test]
4336 fn test_openai_non_reasoning_models_reject_thinking() {
4337 let provider = OpenAIProvider::gpt4o("test-key".to_string());
4338 let error = provider
4339 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
4340 .unwrap_err();
4341 assert!(error.to_string().contains("thinking is not supported"));
4342 }
4343
4344 #[test]
4345 fn test_request_serialization_openai_uses_max_completion_tokens_only() {
4346 let messages = vec![ApiMessage {
4347 role: ApiRole::User,
4348 content: Some("Hello".to_string()),
4349 reasoning_content: None,
4350 tool_calls: None,
4351 tool_call_id: None,
4352 prompt_cache_breakpoint: false,
4353 }];
4354
4355 let request = ApiChatRequest {
4356 model: "gpt-4o",
4357 messages: &messages,
4358 max_completion_tokens: Some(1024),
4359 max_tokens: None,
4360 tools: None,
4361 tool_choice: None,
4362 reasoning: None,
4363 response_format: None,
4364 verbosity: None,
4365 prompt_cache_options: None,
4366 store: Some(false),
4367 parallel_tool_calls: None,
4368 safety_identifier: None,
4369 prompt_cache_key: None,
4370 };
4371
4372 let json = serde_json::to_string(&request).unwrap();
4373 assert!(json.contains("\"max_completion_tokens\":1024"));
4374 assert!(!json.contains("\"max_tokens\""));
4375 assert!(json.contains("\"store\":false"));
4376 }
4377
4378 #[test]
4379 fn custom_chat_omits_first_party_storage_and_cache_defaults() {
4380 let explicit_store = OpenAIReasoningConfig::new().with_store(true);
4381
4382 assert_eq!(chat_store(DEFAULT_BASE_URL, None), Some(false));
4383 assert_eq!(
4384 chat_store("https://gateway.example/v1", Some(&explicit_store)),
4385 Some(true)
4386 );
4387 assert_eq!(chat_store("https://gateway.example/v1", None), None);
4388 assert_eq!(
4389 chat_prompt_cache_key(DEFAULT_BASE_URL, Some("thread-42")),
4390 Some("thread-42")
4391 );
4392 assert_eq!(
4393 chat_prompt_cache_key("https://gateway.example/v1", Some("thread-42")),
4394 None
4395 );
4396 }
4397
4398 #[test]
4399 fn official_openai_url_detection_uses_the_exact_parsed_host() {
4400 assert!(is_official_openai_base_url(DEFAULT_BASE_URL));
4401 assert!(is_official_openai_base_url(
4402 "https://api.openai.com/custom-prefix/v1"
4403 ));
4404 assert!(!is_official_openai_base_url(
4405 "https://api.openai.com.attacker.example/v1"
4406 ));
4407 assert!(!is_official_openai_base_url(
4408 "https://gateway.example/v1?upstream=api.openai.com"
4409 ));
4410 assert!(!is_official_openai_base_url("not a URL api.openai.com"));
4411 }
4412
4413 #[test]
4414 fn test_request_serializes_openai_application_controls() -> anyhow::Result<()> {
4415 let messages = vec![ApiMessage {
4416 role: ApiRole::User,
4417 content: Some("Hello".to_owned()),
4418 reasoning_content: None,
4419 tool_calls: None,
4420 tool_call_id: None,
4421 prompt_cache_breakpoint: false,
4422 }];
4423 let request = ApiChatRequest {
4424 model: MODEL_GPT56,
4425 messages: &messages,
4426 max_completion_tokens: Some(1024),
4427 max_tokens: None,
4428 tools: None,
4429 tool_choice: None,
4430 reasoning: None,
4431 response_format: None,
4432 verbosity: None,
4433 prompt_cache_options: None,
4434 store: Some(true),
4435 parallel_tool_calls: Some(false),
4436 safety_identifier: Some("safety-user-42"),
4437 prompt_cache_key: Some("thread-42"),
4438 };
4439
4440 let json = serde_json::to_value(request)?;
4441 assert_eq!(json["store"], true);
4442 assert_eq!(json["parallel_tool_calls"], false);
4443 assert_eq!(json["safety_identifier"], "safety-user-42");
4444 assert_eq!(json["prompt_cache_key"], "thread-42");
4445 Ok(())
4446 }
4447
4448 #[test]
4449 fn test_request_serialization_with_max_tokens_alias() {
4450 let messages = vec![ApiMessage {
4451 role: ApiRole::User,
4452 content: Some("Hello".to_string()),
4453 reasoning_content: None,
4454 tool_calls: None,
4455 tool_call_id: None,
4456 prompt_cache_breakpoint: false,
4457 }];
4458
4459 let request = ApiChatRequest {
4460 model: "glm-5",
4461 messages: &messages,
4462 max_completion_tokens: Some(1024),
4463 max_tokens: Some(1024),
4464 tools: None,
4465 tool_choice: None,
4466 reasoning: None,
4467 response_format: None,
4468 verbosity: None,
4469 prompt_cache_options: None,
4470 store: None,
4471 parallel_tool_calls: None,
4472 safety_identifier: None,
4473 prompt_cache_key: None,
4474 };
4475
4476 let json = serde_json::to_string(&request).unwrap();
4477 assert!(json.contains("\"max_completion_tokens\":1024"));
4478 assert!(json.contains("\"max_tokens\":1024"));
4479 assert!(!json.contains("\"store\""));
4480 }
4481
4482 #[test]
4483 fn test_streaming_request_serialization_openai_default() {
4484 let messages = vec![ApiMessage {
4485 role: ApiRole::User,
4486 content: Some("Hello".to_string()),
4487 reasoning_content: None,
4488 tool_calls: None,
4489 tool_call_id: None,
4490 prompt_cache_breakpoint: false,
4491 }];
4492
4493 let request = ApiChatRequestStreaming {
4494 model: "gpt-4o",
4495 messages: &messages,
4496 max_completion_tokens: Some(1024),
4497 max_tokens: None,
4498 tools: None,
4499 tool_choice: None,
4500 reasoning: None,
4501 response_format: None,
4502 verbosity: None,
4503 prompt_cache_options: None,
4504 store: Some(false),
4505 parallel_tool_calls: None,
4506 safety_identifier: None,
4507 prompt_cache_key: None,
4508 stream_options: Some(ApiStreamOptions {
4509 include_usage: true,
4510 }),
4511 usage: None,
4512 stream: true,
4513 };
4514
4515 let json = serde_json::to_string(&request).unwrap();
4516 assert!(json.contains("\"stream\":true"));
4517 assert!(json.contains("\"model\":\"gpt-4o\""));
4518 assert!(json.contains("\"max_completion_tokens\":1024"));
4519 assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
4520 assert!(!json.contains("\"max_tokens\""));
4521 }
4522
4523 #[test]
4524 fn stream_usage_is_requested_for_every_endpoint() {
4525 assert!(use_stream_usage_options("https://api.openai.com/v1"));
4529 assert!(use_stream_usage_options("https://openrouter.ai/api/v1"));
4530 assert!(use_stream_usage_options("https://host.baseten.co/v1"));
4531 assert!(use_stream_usage_options("http://localhost:1234/v1"));
4532 }
4533
4534 #[test]
4535 fn openrouter_usage_flag_only_for_openrouter() {
4536 assert!(use_openrouter_usage_options("https://openrouter.ai/api/v1"));
4537 assert!(!use_openrouter_usage_options("https://api.openai.com/v1"));
4538 }
4539
4540 #[test]
4541 fn serving_route_names_the_gateway_behind_the_base_url() {
4542 assert_eq!(serving_route("https://openrouter.ai/api/v1"), "openrouter");
4543 assert_eq!(serving_route("https://model.baseten.co/v1"), "baseten");
4544 assert_eq!(
4545 serving_route("https://api.fireworks.ai/inference/v1"),
4546 "fireworks",
4547 );
4548 assert_eq!(serving_route("https://api.openai.com/v1"), "openai");
4549 assert_eq!(serving_route("http://localhost:1234/v1"), "openai");
4550 }
4551
4552 #[test]
4553 fn route_distinguishes_gateways_that_share_one_provider_identity() {
4554 let native = OpenAIProvider::with_base_url("k", "gpt-5.6", "https://api.openai.com/v1");
4555 let gateway = OpenAIProvider::with_base_url("k", "gpt-5.6", "https://openrouter.ai/api/v1");
4556
4557 assert_eq!(native.provider(), gateway.provider());
4560 assert_eq!(native.route(), "openai");
4561 assert_eq!(gateway.route(), "openrouter");
4562 assert_ne!(native.route(), gateway.route());
4563 }
4564
4565 #[test]
4566 fn streaming_request_serializes_openrouter_usage_flag() -> anyhow::Result<()> {
4567 let messages = vec![ApiMessage {
4568 role: ApiRole::User,
4569 content: Some("hi".to_string()),
4570 reasoning_content: None,
4571 tool_calls: None,
4572 tool_call_id: None,
4573 prompt_cache_breakpoint: false,
4574 }];
4575 let request = ApiChatRequestStreaming {
4576 model: "anthropic/claude-3.5",
4577 messages: &messages,
4578 max_completion_tokens: Some(16),
4579 max_tokens: None,
4580 tools: None,
4581 tool_choice: None,
4582 reasoning: None,
4583 response_format: None,
4584 verbosity: None,
4585 prompt_cache_options: None,
4586 store: None,
4587 parallel_tool_calls: None,
4588 safety_identifier: None,
4589 prompt_cache_key: None,
4590 stream_options: Some(ApiStreamOptions {
4591 include_usage: true,
4592 }),
4593 usage: Some(ApiOpenRouterUsageOptions { include: true }),
4594 stream: true,
4595 };
4596 let json = serde_json::to_string(&request)?;
4597 assert!(json.contains("\"usage\":{\"include\":true}"));
4598 assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
4599 Ok(())
4600 }
4601
4602 #[test]
4603 fn usage_only_chunk_without_choices_deserializes() -> anyhow::Result<()> {
4604 let no_choices: SseChunk = serde_json::from_str("{}")?;
4608 assert!(no_choices.choices.is_empty());
4609
4610 let usage_only: SseChunk =
4611 serde_json::from_str(r#"{"usage":{"prompt_tokens":10,"completion_tokens":5}}"#)?;
4612 assert!(usage_only.choices.is_empty());
4613 assert!(usage_only.usage.is_some());
4614 Ok(())
4615 }
4616
4617 #[test]
4618 fn test_streaming_request_serialization_with_max_tokens_alias() {
4619 let messages = vec![ApiMessage {
4620 role: ApiRole::User,
4621 content: Some("Hello".to_string()),
4622 reasoning_content: None,
4623 tool_calls: None,
4624 tool_call_id: None,
4625 prompt_cache_breakpoint: false,
4626 }];
4627
4628 let request = ApiChatRequestStreaming {
4629 model: "kimi-k2-thinking",
4630 messages: &messages,
4631 max_completion_tokens: Some(1024),
4632 max_tokens: Some(1024),
4633 tools: None,
4634 tool_choice: None,
4635 reasoning: None,
4636 response_format: None,
4637 verbosity: None,
4638 prompt_cache_options: None,
4639 store: None,
4640 parallel_tool_calls: None,
4641 safety_identifier: None,
4642 prompt_cache_key: None,
4643 stream_options: None,
4644 usage: None,
4645 stream: true,
4646 };
4647
4648 let json = serde_json::to_string(&request).unwrap();
4649 assert!(json.contains("\"max_completion_tokens\":1024"));
4650 assert!(json.contains("\"max_tokens\":1024"));
4651 assert!(!json.contains("\"stream_options\""));
4652 assert!(!json.contains("\"store\""));
4653 }
4654
4655 #[test]
4656 fn test_request_serialization_uses_top_level_reasoning_effort() -> anyhow::Result<()> {
4657 let messages = vec![ApiMessage {
4658 role: ApiRole::User,
4659 content: Some("Hello".to_string()),
4660 reasoning_content: None,
4661 tool_calls: None,
4662 tool_call_id: None,
4663 prompt_cache_breakpoint: false,
4664 }];
4665
4666 let request = ApiChatRequest {
4667 model: MODEL_GPT54,
4668 messages: &messages,
4669 max_completion_tokens: Some(1024),
4670 max_tokens: None,
4671 tools: None,
4672 tool_choice: None,
4673 reasoning: build_chat_api_reasoning(
4674 Some(&OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::High)),
4675 true,
4676 ),
4677 response_format: None,
4678 verbosity: None,
4679 prompt_cache_options: None,
4680 store: Some(false),
4681 parallel_tool_calls: None,
4682 safety_identifier: None,
4683 prompt_cache_key: None,
4684 };
4685
4686 let json = serde_json::to_value(&request)?;
4687 assert_eq!(json["reasoning_effort"], "high");
4688 assert!(json.get("reasoning").is_none());
4689 Ok(())
4690 }
4691
4692 #[test]
4693 fn test_compatible_request_serialization_preserves_nested_reasoning() -> anyhow::Result<()> {
4694 let messages = vec![ApiMessage {
4695 role: ApiRole::User,
4696 content: Some("Hello".to_string()),
4697 reasoning_content: None,
4698 tool_calls: None,
4699 tool_call_id: None,
4700 prompt_cache_breakpoint: false,
4701 }];
4702 let reasoning = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::High);
4703
4704 let request = ApiChatRequest {
4705 model: "compatible-model",
4706 messages: &messages,
4707 max_completion_tokens: Some(1024),
4708 max_tokens: None,
4709 tools: None,
4710 tool_choice: None,
4711 reasoning: build_chat_api_reasoning(Some(&reasoning), false),
4712 response_format: None,
4713 verbosity: None,
4714 prompt_cache_options: None,
4715 store: None,
4716 parallel_tool_calls: None,
4717 safety_identifier: None,
4718 prompt_cache_key: None,
4719 };
4720
4721 let json = serde_json::to_value(&request)?;
4722 assert_eq!(json["reasoning"]["effort"], "high");
4723 assert!(json.get("reasoning_effort").is_none());
4724 Ok(())
4725 }
4726
4727 #[test]
4728 fn test_response_format_serializes_as_json_schema() {
4729 let messages = vec![ApiMessage {
4730 role: ApiRole::User,
4731 content: Some("Hello".to_string()),
4732 reasoning_content: None,
4733 tool_calls: None,
4734 tool_call_id: None,
4735 prompt_cache_breakpoint: false,
4736 }];
4737
4738 let response_format = Some(ApiResponseFormat::from_response_format(
4739 &agent_sdk_foundation::llm::ResponseFormat::new(
4740 "person",
4741 serde_json::json!({"type": "object"}),
4742 ),
4743 true,
4744 ));
4745
4746 let request = ApiChatRequest {
4747 model: "gpt-4o",
4748 messages: &messages,
4749 max_completion_tokens: Some(1024),
4750 max_tokens: None,
4751 tools: None,
4752 tool_choice: None,
4753 reasoning: None,
4754 response_format,
4755 verbosity: None,
4756 prompt_cache_options: None,
4757 store: Some(false),
4758 parallel_tool_calls: None,
4759 safety_identifier: None,
4760 prompt_cache_key: None,
4761 };
4762
4763 let json = serde_json::to_value(&request).unwrap();
4764 assert_eq!(json["response_format"]["type"], "json_schema");
4765 assert_eq!(json["response_format"]["json_schema"]["name"], "person");
4766 assert_eq!(json["response_format"]["json_schema"]["strict"], true);
4767 assert_eq!(
4768 json["response_format"]["json_schema"]["schema"]["type"],
4769 "object"
4770 );
4771 }
4772
4773 #[test]
4774 fn test_step_completion_stream_emits_trailing_usage_after_finish_reason() {
4775 let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
4781 let mut usage: Option<Usage> = None;
4782 let mut stop_reason: Option<StopReason> = None;
4783
4784 let o1 = step_completion_stream(
4786 r#"{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}"#,
4787 &mut tool_calls,
4788 &mut usage,
4789 &mut stop_reason,
4790 );
4791 assert!(o1.terminal.is_none());
4792 assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
4793
4794 let o2 = step_completion_stream(
4796 r#"{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
4797 &mut tool_calls,
4798 &mut usage,
4799 &mut stop_reason,
4800 );
4801 assert!(o2.terminal.is_none());
4802
4803 let o3 = step_completion_stream("[DONE]", &mut tool_calls, &mut usage, &mut stop_reason);
4805 let terminal = o3.terminal.expect("[DONE] finalizes the stream");
4806 assert!(terminal.iter().any(|d| matches!(
4807 d,
4808 StreamDelta::Usage(Usage {
4809 input_tokens: 10,
4810 output_tokens: 5,
4811 ..
4812 })
4813 )));
4814 assert!(terminal.iter().any(|d| matches!(
4815 d,
4816 StreamDelta::Done {
4817 stop_reason: Some(StopReason::EndTurn)
4818 }
4819 )));
4820 }
4821
4822 #[test]
4823 fn malformed_chat_sse_is_an_immediate_server_error() -> anyhow::Result<()> {
4824 let mut tool_calls = HashMap::new();
4825 let mut usage = None;
4826 let mut stop_reason = None;
4827 let outcome = step_completion_stream(
4828 "{not valid json",
4829 &mut tool_calls,
4830 &mut usage,
4831 &mut stop_reason,
4832 );
4833 let SseLineOutcome {
4834 immediate,
4835 terminal,
4836 } = outcome;
4837 let terminal = terminal.context("malformed SSE event did not terminate the stream")?;
4838
4839 assert!(immediate.is_empty());
4840 assert!(matches!(
4841 terminal.as_slice(),
4842 [StreamDelta::Error {
4843 kind: StreamErrorKind::ServerError,
4844 ..
4845 }]
4846 ));
4847 Ok(())
4848 }
4849
4850 #[test]
4851 fn non_tool_stream_terminal_suppresses_accumulated_tool_calls() {
4852 let tool_calls = HashMap::from([(
4853 0,
4854 ToolCallAccumulator {
4855 id: "call_partial".to_owned(),
4856 name: "delete_record".to_owned(),
4857 arguments: "{\"id\":".to_owned(),
4858 },
4859 )]);
4860
4861 let truncated = build_stream_end_deltas(&tool_calls, None, StopReason::MaxTokens);
4862 assert!(!truncated.iter().any(|delta| matches!(
4863 delta,
4864 StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
4865 )));
4866
4867 let tool_terminal = build_stream_end_deltas(&tool_calls, None, StopReason::ToolUse);
4868 assert!(
4869 tool_terminal
4870 .iter()
4871 .any(|delta| matches!(delta, StreamDelta::ToolUseStart { .. }))
4872 );
4873 }
4874
4875 #[test]
4876 fn test_response_format_omitted_when_absent() {
4877 let messages = vec![ApiMessage {
4878 role: ApiRole::User,
4879 content: Some("Hello".to_string()),
4880 reasoning_content: None,
4881 tool_calls: None,
4882 tool_call_id: None,
4883 prompt_cache_breakpoint: false,
4884 }];
4885
4886 let request = ApiChatRequest {
4887 model: "gpt-4o",
4888 messages: &messages,
4889 max_completion_tokens: Some(1024),
4890 max_tokens: None,
4891 tools: None,
4892 tool_choice: None,
4893 reasoning: None,
4894 response_format: None,
4895 verbosity: None,
4896 prompt_cache_options: None,
4897 store: Some(false),
4898 parallel_tool_calls: None,
4899 safety_identifier: None,
4900 prompt_cache_key: None,
4901 };
4902
4903 let json = serde_json::to_string(&request).unwrap();
4904 assert!(!json.contains("response_format"));
4905 }
4906
4907 #[tokio::test]
4908 async fn stream_eof_before_done_is_an_error() -> anyhow::Result<()> {
4909 use wiremock::matchers::{method, path};
4910 use wiremock::{Mock, MockServer, ResponseTemplate};
4911
4912 let server = MockServer::start().await;
4913 Mock::given(method("POST"))
4914 .and(path("/chat/completions"))
4915 .respond_with(
4916 ResponseTemplate::new(200)
4917 .insert_header("content-type", "text/event-stream")
4918 .set_body_string(
4919 "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"stop\"}]}\n\n",
4920 ),
4921 )
4922 .mount(&server)
4923 .await;
4924
4925 let provider = OpenAIProvider::with_base_url("test-key", MODEL_GPT4O, server.uri());
4926 let request = ChatRequest::new(
4927 String::new(),
4928 vec![agent_sdk_foundation::llm::Message::user("hello")],
4929 );
4930 let items = provider.chat_stream(request).collect::<Vec<_>>().await;
4931
4932 assert!(items.iter().any(|item| matches!(
4933 item,
4934 Ok(StreamDelta::TextDelta { delta, .. }) if delta == "partial"
4935 )));
4936 assert!(
4937 !items
4938 .iter()
4939 .any(|item| matches!(item, Ok(StreamDelta::Done { .. })))
4940 );
4941 let last = items.last().context("stream emitted no events")?;
4942 let Err(error) = last else {
4943 anyhow::bail!("truncated stream did not end with an error");
4944 };
4945 assert!(error.to_string().contains("before [DONE] sentinel"));
4946 Ok(())
4947 }
4948
4949 #[tokio::test]
4950 async fn stream_usage_before_eof_is_surfaced_ahead_of_the_error() -> anyhow::Result<()> {
4951 use wiremock::matchers::{method, path};
4952 use wiremock::{Mock, MockServer, ResponseTemplate};
4953
4954 let server = MockServer::start().await;
4958 Mock::given(method("POST"))
4959 .and(path("/chat/completions"))
4960 .respond_with(
4961 ResponseTemplate::new(200)
4962 .insert_header("content-type", "text/event-stream")
4963 .set_body_string(
4964 "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"stop\"}]}\n\n\
4965 data: {\"choices\":[],\"usage\":{\"prompt_tokens\":140,\"completion_tokens\":20}}\n\n",
4966 ),
4967 )
4968 .mount(&server)
4969 .await;
4970
4971 let provider = OpenAIProvider::with_base_url("test-key", MODEL_GPT4O, server.uri());
4972 let request = ChatRequest::new(
4973 String::new(),
4974 vec![agent_sdk_foundation::llm::Message::user("hello")],
4975 );
4976 let items = provider.chat_stream(request).collect::<Vec<_>>().await;
4977
4978 let usage_at = items
4979 .iter()
4980 .position(|item| matches!(item, Ok(StreamDelta::Usage(_))))
4981 .context("the truncated stream's usage must not be dropped")?;
4982 let error_at = items
4983 .iter()
4984 .position(Result::is_err)
4985 .context("a truncated stream must end with an error")?;
4986 assert!(
4987 usage_at < error_at,
4988 "usage must be surfaced before the transport error, got {items:?}"
4989 );
4990 let Ok(StreamDelta::Usage(usage)) = &items[usage_at] else {
4991 anyhow::bail!("expected a usage delta");
4992 };
4993 assert_eq!(usage.input_tokens, 140);
4994 assert_eq!(usage.output_tokens, 20);
4995 Ok(())
4996 }
4997}