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