1use crate::attachments::{request_has_attachments, validate_request_attachments};
11use crate::provider::LlmProvider;
12use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
13use agent_sdk_foundation::llm::{
14 ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, StopReason,
15 ThinkingConfig, ThinkingMode, Usage,
16};
17use anyhow::Result;
18use async_trait::async_trait;
19use futures::StreamExt;
20use reqwest::StatusCode;
21use serde::de::Error as _;
22use serde::{Deserialize, Serialize};
23
24use super::openai_responses::OpenAIResponsesProvider;
25
26const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
27
28fn requires_responses_api(model: &str) -> bool {
30 model == MODEL_GPT52_CODEX
31}
32
33fn is_official_openai_base_url(base_url: &str) -> bool {
34 base_url == DEFAULT_BASE_URL || base_url.contains("api.openai.com")
35}
36
37fn request_is_agentic(request: &ChatRequest) -> bool {
38 request
39 .tools
40 .as_ref()
41 .is_some_and(|tools| !tools.is_empty()) || request.messages.iter().any(|message| {
42 matches!(
43 &message.content,
44 Content::Blocks(blocks)
45 if blocks.iter().any(|block| {
46 matches!(block, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. })
47 })
48 )
49 })
50}
51
52fn should_use_responses_api(base_url: &str, model: &str, request: &ChatRequest) -> bool {
53 requires_responses_api(model)
54 || request_has_attachments(request)
55 || (is_official_openai_base_url(base_url) && request_is_agentic(request))
56}
57
58pub const MODEL_GPT54: &str = "gpt-5.4";
60
61pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
63
64pub const MODEL_GPT52_INSTANT: &str = "gpt-5.2-instant";
66pub const MODEL_GPT52_THINKING: &str = "gpt-5.2-thinking";
67pub const MODEL_GPT52_PRO: &str = "gpt-5.2-pro";
68pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
69
70pub const MODEL_GPT5: &str = "gpt-5";
72pub const MODEL_GPT5_MINI: &str = "gpt-5-mini";
73pub const MODEL_GPT5_NANO: &str = "gpt-5-nano";
74
75pub const MODEL_O3: &str = "o3";
77pub const MODEL_O3_MINI: &str = "o3-mini";
78pub const MODEL_O4_MINI: &str = "o4-mini";
79pub const MODEL_O1: &str = "o1";
80pub const MODEL_O1_MINI: &str = "o1-mini";
81
82pub const MODEL_GPT41: &str = "gpt-4.1";
84pub const MODEL_GPT41_MINI: &str = "gpt-4.1-mini";
85pub const MODEL_GPT41_NANO: &str = "gpt-4.1-nano";
86
87pub const MODEL_GPT4O: &str = "gpt-4o";
89pub const MODEL_GPT4O_MINI: &str = "gpt-4o-mini";
90
91pub const BASE_URL_KIMI: &str = "https://api.moonshot.ai/v1";
93pub const BASE_URL_ZAI: &str = "https://api.z.ai/api/paas/v4";
94pub const BASE_URL_MINIMAX: &str = "https://api.minimax.io/v1";
95pub const MODEL_KIMI_K2_5: &str = "kimi-k2.5";
96pub const MODEL_KIMI_K2_THINKING: &str = "kimi-k2-thinking";
97pub const MODEL_ZAI_GLM5: &str = "glm-5";
98pub const MODEL_MINIMAX_M2_5: &str = "MiniMax-M2.5";
99
100#[derive(Clone)]
105pub struct OpenAIProvider {
106 client: reqwest::Client,
107 api_key: String,
108 model: String,
109 base_url: String,
110 thinking: Option<ThinkingConfig>,
111 extra_headers: Vec<(String, String)>,
113}
114
115impl OpenAIProvider {
116 pub const API_KEY_ENV: &'static str = "OPENAI_API_KEY";
118
119 #[must_use]
121 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
122 Self {
123 client: reqwest::Client::new(),
124 api_key: api_key.into(),
125 model: model.into(),
126 base_url: DEFAULT_BASE_URL.to_owned(),
127 thinking: None,
128 extra_headers: Vec::new(),
129 }
130 }
131
132 #[must_use]
140 pub fn from_env() -> Self {
141 Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
142 }
143
144 pub fn try_from_env() -> Result<Self> {
151 let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
152 anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
153 })?;
154 Ok(Self::gpt5(api_key))
155 }
156
157 #[must_use]
159 pub fn with_base_url(
160 api_key: impl Into<String>,
161 model: impl Into<String>,
162 base_url: impl Into<String>,
163 ) -> Self {
164 Self {
165 client: reqwest::Client::new(),
166 api_key: api_key.into(),
167 model: model.into(),
168 base_url: base_url.into(),
169 thinking: None,
170 extra_headers: Vec::new(),
171 }
172 }
173
174 #[must_use]
176 pub fn kimi(api_key: String, model: String) -> Self {
177 Self::with_base_url(api_key, model, BASE_URL_KIMI.to_owned())
178 }
179
180 #[must_use]
182 pub fn kimi_k2_5(api_key: String) -> Self {
183 Self::kimi(api_key, MODEL_KIMI_K2_5.to_owned())
184 }
185
186 #[must_use]
188 pub fn kimi_k2_thinking(api_key: String) -> Self {
189 Self::kimi(api_key, MODEL_KIMI_K2_THINKING.to_owned())
190 }
191
192 #[must_use]
194 pub fn zai(api_key: String, model: String) -> Self {
195 Self::with_base_url(api_key, model, BASE_URL_ZAI.to_owned())
196 }
197
198 #[must_use]
200 pub fn zai_glm5(api_key: String) -> Self {
201 Self::zai(api_key, MODEL_ZAI_GLM5.to_owned())
202 }
203
204 #[must_use]
206 pub fn minimax(api_key: String, model: String) -> Self {
207 Self::with_base_url(api_key, model, BASE_URL_MINIMAX.to_owned())
208 }
209
210 #[must_use]
212 pub fn minimax_m2_5(api_key: String) -> Self {
213 Self::minimax(api_key, MODEL_MINIMAX_M2_5.to_owned())
214 }
215
216 #[must_use]
218 pub fn gpt52_instant(api_key: String) -> Self {
219 Self::new(api_key, MODEL_GPT52_INSTANT.to_owned())
220 }
221
222 #[must_use]
224 pub fn gpt54(api_key: String) -> Self {
225 Self::new(api_key, MODEL_GPT54.to_owned())
226 }
227
228 #[must_use]
230 pub fn gpt53_codex(api_key: String) -> Self {
231 Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
232 }
233
234 #[must_use]
236 pub fn gpt52_thinking(api_key: String) -> Self {
237 Self::new(api_key, MODEL_GPT52_THINKING.to_owned())
238 }
239
240 #[must_use]
242 pub fn gpt52_pro(api_key: String) -> Self {
243 Self::new(api_key, MODEL_GPT52_PRO.to_owned())
244 }
245
246 #[must_use]
248 pub fn codex(api_key: String) -> Self {
249 Self::gpt53_codex(api_key)
250 }
251
252 #[must_use]
254 pub fn gpt5(api_key: String) -> Self {
255 Self::new(api_key, MODEL_GPT5.to_owned())
256 }
257
258 #[must_use]
260 pub fn gpt5_mini(api_key: String) -> Self {
261 Self::new(api_key, MODEL_GPT5_MINI.to_owned())
262 }
263
264 #[must_use]
266 pub fn gpt5_nano(api_key: String) -> Self {
267 Self::new(api_key, MODEL_GPT5_NANO.to_owned())
268 }
269
270 #[must_use]
272 pub fn o3(api_key: String) -> Self {
273 Self::new(api_key, MODEL_O3.to_owned())
274 }
275
276 #[must_use]
278 pub fn o3_mini(api_key: String) -> Self {
279 Self::new(api_key, MODEL_O3_MINI.to_owned())
280 }
281
282 #[must_use]
284 pub fn o4_mini(api_key: String) -> Self {
285 Self::new(api_key, MODEL_O4_MINI.to_owned())
286 }
287
288 #[must_use]
290 pub fn o1(api_key: String) -> Self {
291 Self::new(api_key, MODEL_O1.to_owned())
292 }
293
294 #[must_use]
296 pub fn o1_mini(api_key: String) -> Self {
297 Self::new(api_key, MODEL_O1_MINI.to_owned())
298 }
299
300 #[must_use]
302 pub fn gpt41(api_key: String) -> Self {
303 Self::new(api_key, MODEL_GPT41.to_owned())
304 }
305
306 #[must_use]
308 pub fn gpt41_mini(api_key: String) -> Self {
309 Self::new(api_key, MODEL_GPT41_MINI.to_owned())
310 }
311
312 #[must_use]
314 pub fn gpt4o(api_key: String) -> Self {
315 Self::new(api_key, MODEL_GPT4O.to_owned())
316 }
317
318 #[must_use]
320 pub fn gpt4o_mini(api_key: String) -> Self {
321 Self::new(api_key, MODEL_GPT4O_MINI.to_owned())
322 }
323
324 #[must_use]
326 pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
327 self.thinking = Some(thinking);
328 self
329 }
330
331 #[must_use]
333 pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
334 self.extra_headers = headers;
335 self
336 }
337
338 fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
341 let builder = if self.api_key.is_empty() {
342 builder
343 } else {
344 builder.header("Authorization", format!("Bearer {}", self.api_key))
345 };
346 self.extra_headers
347 .iter()
348 .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
349 }
350}
351
352#[async_trait]
353impl LlmProvider for OpenAIProvider {
354 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
355 if should_use_responses_api(&self.base_url, &self.model, &request) {
357 let mut responses_provider = OpenAIResponsesProvider::with_base_url(
358 self.api_key.clone(),
359 self.model.clone(),
360 self.base_url.clone(),
361 );
362 if let Some(thinking) = self.thinking.clone() {
363 responses_provider = responses_provider.with_thinking(thinking);
364 }
365 return responses_provider.chat(request).await;
366 }
367
368 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
369 Ok(thinking) => thinking,
370 Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
371 };
372 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
373 return Ok(ChatOutcome::InvalidRequest(error.to_string()));
374 }
375 let reasoning = build_api_reasoning(thinking_config.as_ref());
376 let messages = build_api_messages(&request);
377 let tools: Option<Vec<ApiTool>> = request
378 .tools
379 .map(|ts| ts.into_iter().map(convert_tool).collect());
380 let tool_choice = request
381 .tool_choice
382 .as_ref()
383 .map(ApiToolChoice::from_tool_choice);
384 let response_format = request
385 .response_format
386 .as_ref()
387 .map(ApiResponseFormat::from_response_format);
388
389 let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
390 let api_request = ApiChatRequest {
391 model: &self.model,
392 messages: &messages,
393 max_completion_tokens: Some(request.max_tokens),
394 max_tokens: include_max_tokens_alias.then_some(request.max_tokens),
395 tools: tools.as_deref(),
396 tool_choice,
397 reasoning,
398 response_format,
399 };
400
401 log::debug!(
402 "OpenAI LLM request model={} max_tokens={}",
403 self.model,
404 request.max_tokens
405 );
406
407 let builder = self
408 .client
409 .post(format!("{}/chat/completions", self.base_url))
410 .header("Content-Type", "application/json");
411 let response = self
412 .apply_headers(builder)
413 .json(&api_request)
414 .send()
415 .await
416 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
417
418 let status = response.status();
419 let bytes = response
420 .bytes()
421 .await
422 .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
423
424 log::debug!(
425 "OpenAI LLM response status={} body_len={}",
426 status,
427 bytes.len()
428 );
429
430 decode_chat_response(status, &bytes)
431 }
432
433 #[allow(clippy::too_many_lines)]
434 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
435 if should_use_responses_api(&self.base_url, &self.model, &request) {
437 let api_key = self.api_key.clone();
438 let model = self.model.clone();
439 let base_url = self.base_url.clone();
440 let thinking = self.thinking.clone();
441 return Box::pin(async_stream::stream! {
442 let mut responses_provider =
443 OpenAIResponsesProvider::with_base_url(api_key, model, base_url);
444 if let Some(thinking) = thinking {
445 responses_provider = responses_provider.with_thinking(thinking);
446 }
447 let mut stream = std::pin::pin!(responses_provider.chat_stream(request));
448 while let Some(item) = futures::StreamExt::next(&mut stream).await {
449 yield item;
450 }
451 });
452 }
453
454 Box::pin(async_stream::stream! {
455 let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
456 Ok(thinking) => thinking,
457 Err(error) => {
458 yield Ok(StreamDelta::Error {
459 message: error.to_string(),
460 kind: StreamErrorKind::InvalidRequest,
461 });
462 return;
463 }
464 };
465 if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
466 yield Ok(StreamDelta::Error {
467 message: error.to_string(),
468 kind: StreamErrorKind::InvalidRequest,
469 });
470 return;
471 }
472 let reasoning = build_api_reasoning(thinking_config.as_ref());
473 let messages = build_api_messages(&request);
474 let tools: Option<Vec<ApiTool>> = request
475 .tools
476 .map(|ts| ts.into_iter().map(convert_tool).collect());
477 let tool_choice = request
478 .tool_choice
479 .as_ref()
480 .map(ApiToolChoice::from_tool_choice);
481 let response_format = request
482 .response_format
483 .as_ref()
484 .map(ApiResponseFormat::from_response_format);
485
486 let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
487 let include_stream_usage = use_stream_usage_options(&self.base_url);
488 let api_request = ApiChatRequestStreaming {
489 model: &self.model,
490 messages: &messages,
491 max_completion_tokens: Some(request.max_tokens),
492 max_tokens: include_max_tokens_alias.then_some(request.max_tokens),
493 tools: tools.as_deref(),
494 tool_choice,
495 reasoning,
496 response_format,
497 stream_options: include_stream_usage.then_some(ApiStreamOptions {
498 include_usage: true,
499 }),
500 stream: true,
501 };
502
503 log::debug!("OpenAI streaming LLM request model={} max_tokens={}", self.model, request.max_tokens);
504
505 let stream_builder = self.client
506 .post(format!("{}/chat/completions", self.base_url))
507 .header("Content-Type", "application/json");
508 let Ok(response) = self
509 .apply_headers(stream_builder)
510 .json(&api_request)
511 .send()
512 .await
513 else {
514 yield Err(anyhow::anyhow!("request failed"));
515 return;
516 };
517
518 let status = response.status();
519
520 if !status.is_success() {
521 let body = response.text().await.unwrap_or_default();
522 let (kind, level) = if status == StatusCode::TOO_MANY_REQUESTS {
523 (StreamErrorKind::RateLimited, "rate_limit")
524 } else if status.is_server_error() {
525 (StreamErrorKind::ServerError, "server_error")
526 } else {
527 (StreamErrorKind::InvalidRequest, "client_error")
528 };
529 log::warn!("OpenAI error status={status} body={body} kind={level}");
530 yield Ok(StreamDelta::Error { message: body, kind });
531 return;
532 }
533
534 let mut tool_calls: std::collections::HashMap<usize, ToolCallAccumulator> =
536 std::collections::HashMap::new();
537 let mut usage: Option<Usage> = None;
538 let mut buffer = String::new();
539 let mut stream = response.bytes_stream();
540
541 while let Some(chunk_result) = stream.next().await {
542 let Ok(chunk) = chunk_result else {
543 yield Err(anyhow::anyhow!("stream error: {}", chunk_result.unwrap_err()));
544 return;
545 };
546 buffer.push_str(&String::from_utf8_lossy(&chunk));
547
548 while let Some(pos) = buffer.find('\n') {
549 let line = buffer[..pos].trim().to_string();
550 buffer = buffer[pos + 1..].to_string();
551 if line.is_empty() { continue; }
552 let Some(data) = line.strip_prefix("data: ") else { continue; };
553
554 for result in process_sse_data(data) {
555 match result {
556 SseProcessResult::TextDelta(c) => yield Ok(StreamDelta::TextDelta { delta: c, block_index: 0 }),
557 SseProcessResult::ToolCallUpdate { index, id, name, arguments } => apply_tool_call_update(&mut tool_calls, index, id, name, arguments),
558 SseProcessResult::Usage(u) => usage = Some(u),
559 SseProcessResult::Done(sr) => {
560 for d in build_stream_end_deltas(&tool_calls, usage.take(), sr) { yield Ok(d); }
561 return;
562 }
563 SseProcessResult::Sentinel => {
564 let sr = if tool_calls.is_empty() { StopReason::EndTurn } else { StopReason::ToolUse };
565 for d in build_stream_end_deltas(&tool_calls, usage.take(), sr) { yield Ok(d); }
566 return;
567 }
568 }
569 }
570 }
571 }
572
573 for delta in build_stream_end_deltas(&tool_calls, usage, StopReason::EndTurn) {
575 yield Ok(delta);
576 }
577 })
578 }
579
580 fn model(&self) -> &str {
581 &self.model
582 }
583
584 fn provider(&self) -> &'static str {
585 "openai"
586 }
587
588 fn configured_thinking(&self) -> Option<&ThinkingConfig> {
589 self.thinking.as_ref()
590 }
591}
592
593fn apply_tool_call_update(
595 tool_calls: &mut std::collections::HashMap<usize, ToolCallAccumulator>,
596 index: usize,
597 id: Option<String>,
598 name: Option<String>,
599 arguments: Option<String>,
600) {
601 let entry = tool_calls
602 .entry(index)
603 .or_insert_with(|| ToolCallAccumulator {
604 id: String::new(),
605 name: String::new(),
606 arguments: String::new(),
607 });
608 if let Some(id) = id {
609 entry.id = id;
610 }
611 if let Some(name) = name {
612 entry.name = name;
613 }
614 if let Some(args) = arguments {
615 entry.arguments.push_str(&args);
616 }
617}
618
619fn build_stream_end_deltas(
621 tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
622 usage: Option<Usage>,
623 stop_reason: StopReason,
624) -> Vec<StreamDelta> {
625 let mut deltas = Vec::new();
626
627 for (idx, tool) in tool_calls {
629 deltas.push(StreamDelta::ToolUseStart {
630 id: tool.id.clone(),
631 name: tool.name.clone(),
632 block_index: *idx + 1,
633 thought_signature: None,
634 });
635 deltas.push(StreamDelta::ToolInputDelta {
636 id: tool.id.clone(),
637 delta: tool.arguments.clone(),
638 block_index: *idx + 1,
639 });
640 }
641
642 if let Some(u) = usage {
644 deltas.push(StreamDelta::Usage(u));
645 }
646
647 deltas.push(StreamDelta::Done {
649 stop_reason: Some(stop_reason),
650 });
651
652 deltas
653}
654
655enum SseProcessResult {
657 TextDelta(String),
659 ToolCallUpdate {
661 index: usize,
662 id: Option<String>,
663 name: Option<String>,
664 arguments: Option<String>,
665 },
666 Usage(Usage),
668 Done(StopReason),
670 Sentinel,
672}
673
674fn process_sse_data(data: &str) -> Vec<SseProcessResult> {
676 if data == "[DONE]" {
677 return vec![SseProcessResult::Sentinel];
678 }
679
680 let Ok(chunk) = serde_json::from_str::<SseChunk>(data) else {
681 return vec![];
682 };
683
684 let mut results = Vec::new();
685
686 if let Some(u) = chunk.usage {
688 results.push(SseProcessResult::Usage(Usage {
689 input_tokens: u.prompt_tokens,
690 output_tokens: u.completion_tokens,
691 cached_input_tokens: u
692 .prompt_tokens_details
693 .as_ref()
694 .map_or(0, |details| details.cached_tokens),
695 cache_creation_input_tokens: 0,
696 }));
697 }
698
699 if let Some(choice) = chunk.choices.into_iter().next() {
701 if let Some(content) = choice.delta.content
703 && !content.is_empty()
704 {
705 results.push(SseProcessResult::TextDelta(content));
706 }
707
708 if let Some(tc_deltas) = choice.delta.tool_calls {
710 for tc in tc_deltas {
711 results.push(SseProcessResult::ToolCallUpdate {
712 index: tc.index,
713 id: tc.id,
714 name: tc.function.as_ref().and_then(|f| f.name.clone()),
715 arguments: tc.function.as_ref().and_then(|f| f.arguments.clone()),
716 });
717 }
718 }
719
720 if let Some(finish_reason) = choice.finish_reason {
722 results.push(SseProcessResult::Done(map_finish_reason(&finish_reason)));
723 }
724 }
725
726 results
727}
728
729fn use_max_tokens_alias(base_url: &str) -> bool {
730 base_url.contains("moonshot.ai")
731 || base_url.contains("api.z.ai")
732 || base_url.contains("minimax.io")
733}
734
735fn use_stream_usage_options(base_url: &str) -> bool {
736 base_url == DEFAULT_BASE_URL || base_url.contains("api.openai.com")
737}
738
739fn decode_chat_response(status: StatusCode, bytes: &[u8]) -> Result<ChatOutcome> {
742 if status == StatusCode::TOO_MANY_REQUESTS {
743 return Ok(ChatOutcome::RateLimited);
744 }
745
746 if status.is_server_error() {
747 let body = String::from_utf8_lossy(bytes);
748 log::error!("OpenAI server error status={status} body={body}");
749 return Ok(ChatOutcome::ServerError(body.into_owned()));
750 }
751
752 if status.is_client_error() {
753 let body = String::from_utf8_lossy(bytes);
754 log::warn!("OpenAI client error status={status} body={body}");
755 return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
756 }
757
758 let api_response: ApiChatResponse = serde_json::from_slice(bytes)
759 .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
760
761 let choice = api_response
762 .choices
763 .into_iter()
764 .next()
765 .ok_or_else(|| anyhow::anyhow!("no choices in response"))?;
766
767 let content = build_content_blocks(&choice.message);
768 let stop_reason = choice.finish_reason.as_deref().map(map_finish_reason);
769
770 Ok(ChatOutcome::Success(ChatResponse {
771 id: api_response.id,
772 content,
773 model: api_response.model,
774 stop_reason,
775 usage: Usage {
776 input_tokens: api_response.usage.prompt_tokens,
777 output_tokens: api_response.usage.completion_tokens,
778 cached_input_tokens: api_response
779 .usage
780 .prompt_tokens_details
781 .as_ref()
782 .map_or(0, |details| details.cached_tokens),
783 cache_creation_input_tokens: 0,
784 },
785 }))
786}
787
788fn map_finish_reason(finish_reason: &str) -> StopReason {
789 match finish_reason {
790 "stop" => StopReason::EndTurn,
791 "tool_calls" => StopReason::ToolUse,
792 "length" => StopReason::MaxTokens,
793 "content_filter" | "network_error" => StopReason::StopSequence,
794 "sensitive" => StopReason::Refusal,
795 unknown => {
796 log::debug!("Unknown finish_reason from OpenAI-compatible API: {unknown}");
797 StopReason::StopSequence
798 }
799 }
800}
801
802fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
803 thinking
804 .and_then(resolve_reasoning_effort)
805 .map(|effort| ApiReasoning { effort })
806}
807
808const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
809 if let Some(effort) = config.effort {
810 return Some(map_effort(effort));
811 }
812
813 match &config.mode {
814 ThinkingMode::Adaptive => None,
815 ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
816 }
817}
818
819const fn map_effort(effort: Effort) -> ReasoningEffort {
820 match effort {
821 Effort::Low => ReasoningEffort::Low,
822 Effort::Medium => ReasoningEffort::Medium,
823 Effort::High => ReasoningEffort::High,
824 Effort::Max => ReasoningEffort::XHigh,
825 }
826}
827
828const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
829 if budget_tokens <= 4_096 {
830 ReasoningEffort::Low
831 } else if budget_tokens <= 16_384 {
832 ReasoningEffort::Medium
833 } else if budget_tokens <= 32_768 {
834 ReasoningEffort::High
835 } else {
836 ReasoningEffort::XHigh
837 }
838}
839
840fn build_api_messages(request: &ChatRequest) -> Vec<ApiMessage> {
841 let mut messages = Vec::new();
842
843 if !request.system.is_empty() {
845 messages.push(ApiMessage {
846 role: ApiRole::System,
847 content: Some(request.system.clone()),
848 tool_calls: None,
849 tool_call_id: None,
850 });
851 }
852
853 for msg in &request.messages {
855 match &msg.content {
856 Content::Text(text) => {
857 messages.push(ApiMessage {
858 role: match msg.role {
859 agent_sdk_foundation::llm::Role::User => ApiRole::User,
860 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
861 },
862 content: Some(text.clone()),
863 tool_calls: None,
864 tool_call_id: None,
865 });
866 }
867 Content::Blocks(blocks) => {
868 let mut text_parts = Vec::new();
870 let mut tool_calls = Vec::new();
871
872 for block in blocks {
873 match block {
874 ContentBlock::Text { text } => {
875 text_parts.push(text.clone());
876 }
877 ContentBlock::Thinking { .. }
878 | ContentBlock::RedactedThinking { .. }
879 | ContentBlock::Image { .. }
880 | ContentBlock::Document { .. } => {
881 }
883 ContentBlock::ToolUse {
884 id, name, input, ..
885 } => {
886 tool_calls.push(ApiToolCall {
887 id: id.clone(),
888 r#type: "function".to_owned(),
889 function: ApiFunctionCall {
890 name: name.clone(),
891 arguments: serde_json::to_string(input)
892 .unwrap_or_else(|_| "{}".to_owned()),
893 },
894 });
895 }
896 ContentBlock::ToolResult {
897 tool_use_id,
898 content,
899 ..
900 } => {
901 messages.push(ApiMessage {
903 role: ApiRole::Tool,
904 content: Some(content.clone()),
905 tool_calls: None,
906 tool_call_id: Some(tool_use_id.clone()),
907 });
908 }
909 _ => {
912 log::warn!("Skipping unrecognized OpenAI content block");
913 }
914 }
915 }
916
917 if !text_parts.is_empty() || !tool_calls.is_empty() {
919 let role = match msg.role {
920 agent_sdk_foundation::llm::Role::User => ApiRole::User,
921 agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
922 };
923
924 if role == ApiRole::Assistant || !text_parts.is_empty() {
926 messages.push(ApiMessage {
927 role,
928 content: if text_parts.is_empty() {
929 None
930 } else {
931 Some(text_parts.join("\n"))
932 },
933 tool_calls: if tool_calls.is_empty() {
934 None
935 } else {
936 Some(tool_calls)
937 },
938 tool_call_id: None,
939 });
940 }
941 }
942 }
943 }
944 }
945
946 messages
947}
948
949fn convert_tool(t: agent_sdk_foundation::llm::Tool) -> ApiTool {
950 ApiTool {
951 r#type: "function".to_owned(),
952 function: ApiFunction {
953 name: t.name,
954 description: t.description,
955 parameters: t.input_schema,
956 },
957 }
958}
959
960fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
961 let mut blocks = Vec::new();
962
963 if let Some(content) = &message.content
965 && !content.is_empty()
966 {
967 blocks.push(ContentBlock::Text {
968 text: content.clone(),
969 });
970 }
971
972 if let Some(tool_calls) = &message.tool_calls {
974 for tc in tool_calls {
975 let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
976 .unwrap_or_else(|_| serde_json::json!({}));
977 blocks.push(ContentBlock::ToolUse {
978 id: tc.id.clone(),
979 name: tc.function.name.clone(),
980 input,
981 thought_signature: None,
982 });
983 }
984 }
985
986 blocks
987}
988
989#[derive(Serialize)]
994struct ApiChatRequest<'a> {
995 model: &'a str,
996 messages: &'a [ApiMessage],
997 #[serde(skip_serializing_if = "Option::is_none")]
998 max_completion_tokens: Option<u32>,
999 #[serde(skip_serializing_if = "Option::is_none")]
1000 max_tokens: Option<u32>,
1001 #[serde(skip_serializing_if = "Option::is_none")]
1002 tools: Option<&'a [ApiTool]>,
1003 #[serde(skip_serializing_if = "Option::is_none")]
1004 tool_choice: Option<ApiToolChoice>,
1005 #[serde(skip_serializing_if = "Option::is_none")]
1006 reasoning: Option<ApiReasoning>,
1007 #[serde(skip_serializing_if = "Option::is_none")]
1008 response_format: Option<ApiResponseFormat>,
1009}
1010
1011#[derive(Serialize)]
1012struct ApiChatRequestStreaming<'a> {
1013 model: &'a str,
1014 messages: &'a [ApiMessage],
1015 #[serde(skip_serializing_if = "Option::is_none")]
1016 max_completion_tokens: Option<u32>,
1017 #[serde(skip_serializing_if = "Option::is_none")]
1018 max_tokens: Option<u32>,
1019 #[serde(skip_serializing_if = "Option::is_none")]
1020 tools: Option<&'a [ApiTool]>,
1021 #[serde(skip_serializing_if = "Option::is_none")]
1022 tool_choice: Option<ApiToolChoice>,
1023 #[serde(skip_serializing_if = "Option::is_none")]
1024 reasoning: Option<ApiReasoning>,
1025 #[serde(skip_serializing_if = "Option::is_none")]
1026 response_format: Option<ApiResponseFormat>,
1027 #[serde(skip_serializing_if = "Option::is_none")]
1028 stream_options: Option<ApiStreamOptions>,
1029 stream: bool,
1030}
1031
1032#[derive(Serialize)]
1037#[serde(untagged)]
1038enum ApiToolChoice {
1039 String(String),
1040 Named {
1041 #[serde(rename = "type")]
1042 choice_type: String,
1043 function: ApiToolChoiceFunction,
1044 },
1045}
1046
1047#[derive(Serialize)]
1048struct ApiToolChoiceFunction {
1049 name: String,
1050}
1051
1052impl ApiToolChoice {
1053 fn from_tool_choice(tc: &agent_sdk_foundation::llm::ToolChoice) -> Self {
1054 match tc {
1055 agent_sdk_foundation::llm::ToolChoice::Auto => Self::String("auto".to_owned()),
1056 agent_sdk_foundation::llm::ToolChoice::Tool(name) => Self::Named {
1057 choice_type: "function".to_owned(),
1058 function: ApiToolChoiceFunction { name: name.clone() },
1059 },
1060 }
1061 }
1062}
1063
1064#[derive(Serialize)]
1068struct ApiResponseFormat {
1069 #[serde(rename = "type")]
1070 format_type: &'static str,
1071 json_schema: ApiJsonSchema,
1072}
1073
1074#[derive(Serialize)]
1075struct ApiJsonSchema {
1076 name: String,
1077 schema: serde_json::Value,
1078 strict: bool,
1079}
1080
1081impl ApiResponseFormat {
1082 fn from_response_format(rf: &agent_sdk_foundation::llm::ResponseFormat) -> Self {
1083 Self {
1084 format_type: "json_schema",
1085 json_schema: ApiJsonSchema {
1086 name: rf.name.clone(),
1087 schema: rf.schema.clone(),
1088 strict: rf.strict,
1089 },
1090 }
1091 }
1092}
1093
1094#[derive(Clone, Copy, Serialize)]
1095struct ApiStreamOptions {
1096 include_usage: bool,
1097}
1098
1099#[derive(Clone, Copy, Serialize)]
1100#[serde(rename_all = "lowercase")]
1101enum ReasoningEffort {
1102 Low,
1103 Medium,
1104 High,
1105 #[serde(rename = "xhigh")]
1106 XHigh,
1107}
1108
1109#[derive(Serialize)]
1110struct ApiReasoning {
1111 effort: ReasoningEffort,
1112}
1113
1114#[derive(Serialize)]
1115struct ApiMessage {
1116 role: ApiRole,
1117 #[serde(skip_serializing_if = "Option::is_none")]
1118 content: Option<String>,
1119 #[serde(skip_serializing_if = "Option::is_none")]
1120 tool_calls: Option<Vec<ApiToolCall>>,
1121 #[serde(skip_serializing_if = "Option::is_none")]
1122 tool_call_id: Option<String>,
1123}
1124
1125#[derive(Debug, Serialize, PartialEq, Eq)]
1126#[serde(rename_all = "lowercase")]
1127enum ApiRole {
1128 System,
1129 User,
1130 Assistant,
1131 Tool,
1132}
1133
1134#[derive(Serialize)]
1135struct ApiToolCall {
1136 id: String,
1137 r#type: String,
1138 function: ApiFunctionCall,
1139}
1140
1141#[derive(Serialize)]
1142struct ApiFunctionCall {
1143 name: String,
1144 arguments: String,
1145}
1146
1147#[derive(Serialize)]
1148struct ApiTool {
1149 r#type: String,
1150 function: ApiFunction,
1151}
1152
1153#[derive(Serialize)]
1154struct ApiFunction {
1155 name: String,
1156 description: String,
1157 parameters: serde_json::Value,
1158}
1159
1160#[derive(Deserialize)]
1165struct ApiChatResponse {
1166 id: String,
1167 choices: Vec<ApiChoice>,
1168 model: String,
1169 usage: ApiUsage,
1170}
1171
1172#[derive(Deserialize)]
1173struct ApiChoice {
1174 message: ApiResponseMessage,
1175 finish_reason: Option<String>,
1176}
1177
1178#[derive(Deserialize)]
1179struct ApiResponseMessage {
1180 content: Option<String>,
1181 tool_calls: Option<Vec<ApiResponseToolCall>>,
1182}
1183
1184#[derive(Deserialize)]
1185struct ApiResponseToolCall {
1186 id: String,
1187 function: ApiResponseFunctionCall,
1188}
1189
1190#[derive(Deserialize)]
1191struct ApiResponseFunctionCall {
1192 name: String,
1193 arguments: String,
1194}
1195
1196#[derive(Deserialize)]
1197struct ApiUsage {
1198 #[serde(deserialize_with = "deserialize_u32_from_number")]
1199 prompt_tokens: u32,
1200 #[serde(deserialize_with = "deserialize_u32_from_number")]
1201 completion_tokens: u32,
1202 #[serde(default)]
1203 prompt_tokens_details: Option<ApiPromptTokensDetails>,
1204}
1205
1206#[derive(Deserialize)]
1207struct ApiPromptTokensDetails {
1208 #[serde(default, deserialize_with = "deserialize_u32_from_number")]
1209 cached_tokens: u32,
1210}
1211
1212struct ToolCallAccumulator {
1218 id: String,
1219 name: String,
1220 arguments: String,
1221}
1222
1223#[derive(Deserialize)]
1225struct SseChunk {
1226 choices: Vec<SseChoice>,
1227 #[serde(default)]
1228 usage: Option<SseUsage>,
1229}
1230
1231#[derive(Deserialize)]
1232struct SseChoice {
1233 delta: SseDelta,
1234 finish_reason: Option<String>,
1235}
1236
1237#[derive(Deserialize)]
1238struct SseDelta {
1239 content: Option<String>,
1240 tool_calls: Option<Vec<SseToolCallDelta>>,
1241}
1242
1243#[derive(Deserialize)]
1244struct SseToolCallDelta {
1245 index: usize,
1246 id: Option<String>,
1247 function: Option<SseFunctionDelta>,
1248}
1249
1250#[derive(Deserialize)]
1251struct SseFunctionDelta {
1252 name: Option<String>,
1253 arguments: Option<String>,
1254}
1255
1256#[derive(Deserialize)]
1257struct SseUsage {
1258 #[serde(deserialize_with = "deserialize_u32_from_number")]
1259 prompt_tokens: u32,
1260 #[serde(deserialize_with = "deserialize_u32_from_number")]
1261 completion_tokens: u32,
1262 #[serde(default)]
1263 prompt_tokens_details: Option<ApiPromptTokensDetails>,
1264}
1265
1266fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
1267where
1268 D: serde::Deserializer<'de>,
1269{
1270 #[derive(Deserialize)]
1271 #[serde(untagged)]
1272 enum NumberLike {
1273 U64(u64),
1274 F64(f64),
1275 }
1276
1277 match NumberLike::deserialize(deserializer)? {
1278 NumberLike::U64(v) => u32::try_from(v)
1279 .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
1280 NumberLike::F64(v) => {
1281 if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
1282 v.to_string().parse::<u32>().map_err(|e| {
1283 D::Error::custom(format!(
1284 "failed to convert integer-compatible token count {v} to u32: {e}"
1285 ))
1286 })
1287 } else {
1288 Err(D::Error::custom(format!(
1289 "token count must be a non-negative integer-compatible number, got {v}"
1290 )))
1291 }
1292 }
1293 }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 #[test]
1305 fn test_new_creates_provider_with_custom_model() {
1306 let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
1307
1308 assert_eq!(provider.model(), "custom-model");
1309 assert_eq!(provider.provider(), "openai");
1310 assert_eq!(provider.base_url, DEFAULT_BASE_URL);
1311 }
1312
1313 #[test]
1314 fn test_with_base_url_creates_provider_with_custom_url() {
1315 let provider = OpenAIProvider::with_base_url(
1316 "test-api-key".to_string(),
1317 "llama3".to_string(),
1318 "http://localhost:11434/v1".to_string(),
1319 );
1320
1321 assert_eq!(provider.model(), "llama3");
1322 assert_eq!(provider.base_url, "http://localhost:11434/v1");
1323 }
1324
1325 #[test]
1326 fn test_gpt4o_factory_creates_gpt4o_provider() {
1327 let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
1328
1329 assert_eq!(provider.model(), MODEL_GPT4O);
1330 assert_eq!(provider.provider(), "openai");
1331 }
1332
1333 #[test]
1334 fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
1335 let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
1336
1337 assert_eq!(provider.model(), MODEL_GPT4O_MINI);
1338 assert_eq!(provider.provider(), "openai");
1339 }
1340
1341 #[test]
1342 fn test_gpt52_thinking_factory_creates_provider() {
1343 let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
1344
1345 assert_eq!(provider.model(), MODEL_GPT52_THINKING);
1346 assert_eq!(provider.provider(), "openai");
1347 }
1348
1349 #[test]
1350 fn test_gpt54_factory_creates_provider() {
1351 let provider = OpenAIProvider::gpt54("test-api-key".to_string());
1352
1353 assert_eq!(provider.model(), MODEL_GPT54);
1354 assert_eq!(provider.provider(), "openai");
1355 }
1356
1357 #[test]
1358 fn test_gpt53_codex_factory_creates_provider() {
1359 let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
1360
1361 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1362 assert_eq!(provider.provider(), "openai");
1363 }
1364
1365 #[test]
1366 fn test_codex_factory_points_to_latest_codex_model() {
1367 let provider = OpenAIProvider::codex("test-api-key".to_string());
1368
1369 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1370 assert_eq!(provider.provider(), "openai");
1371 }
1372
1373 #[test]
1374 fn test_gpt5_factory_creates_gpt5_provider() {
1375 let provider = OpenAIProvider::gpt5("test-api-key".to_string());
1376
1377 assert_eq!(provider.model(), MODEL_GPT5);
1378 assert_eq!(provider.provider(), "openai");
1379 }
1380
1381 #[test]
1382 fn test_gpt5_mini_factory_creates_provider() {
1383 let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
1384
1385 assert_eq!(provider.model(), MODEL_GPT5_MINI);
1386 assert_eq!(provider.provider(), "openai");
1387 }
1388
1389 #[test]
1390 fn test_o3_factory_creates_o3_provider() {
1391 let provider = OpenAIProvider::o3("test-api-key".to_string());
1392
1393 assert_eq!(provider.model(), MODEL_O3);
1394 assert_eq!(provider.provider(), "openai");
1395 }
1396
1397 #[test]
1398 fn test_o4_mini_factory_creates_o4_mini_provider() {
1399 let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
1400
1401 assert_eq!(provider.model(), MODEL_O4_MINI);
1402 assert_eq!(provider.provider(), "openai");
1403 }
1404
1405 #[test]
1406 fn test_o1_factory_creates_o1_provider() {
1407 let provider = OpenAIProvider::o1("test-api-key".to_string());
1408
1409 assert_eq!(provider.model(), MODEL_O1);
1410 assert_eq!(provider.provider(), "openai");
1411 }
1412
1413 #[test]
1414 fn test_gpt41_factory_creates_gpt41_provider() {
1415 let provider = OpenAIProvider::gpt41("test-api-key".to_string());
1416
1417 assert_eq!(provider.model(), MODEL_GPT41);
1418 assert_eq!(provider.provider(), "openai");
1419 }
1420
1421 #[test]
1422 fn test_kimi_factory_creates_provider_with_kimi_base_url() {
1423 let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
1424
1425 assert_eq!(provider.model(), "kimi-custom");
1426 assert_eq!(provider.base_url, BASE_URL_KIMI);
1427 assert_eq!(provider.provider(), "openai");
1428 }
1429
1430 #[test]
1431 fn test_kimi_k2_5_factory_creates_provider() {
1432 let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
1433
1434 assert_eq!(provider.model(), MODEL_KIMI_K2_5);
1435 assert_eq!(provider.base_url, BASE_URL_KIMI);
1436 assert_eq!(provider.provider(), "openai");
1437 }
1438
1439 #[test]
1440 fn test_kimi_k2_thinking_factory_creates_provider() {
1441 let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
1442
1443 assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
1444 assert_eq!(provider.base_url, BASE_URL_KIMI);
1445 assert_eq!(provider.provider(), "openai");
1446 }
1447
1448 #[test]
1449 fn test_zai_factory_creates_provider_with_zai_base_url() {
1450 let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
1451
1452 assert_eq!(provider.model(), "glm-custom");
1453 assert_eq!(provider.base_url, BASE_URL_ZAI);
1454 assert_eq!(provider.provider(), "openai");
1455 }
1456
1457 #[test]
1458 fn test_zai_glm5_factory_creates_provider() {
1459 let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
1460
1461 assert_eq!(provider.model(), MODEL_ZAI_GLM5);
1462 assert_eq!(provider.base_url, BASE_URL_ZAI);
1463 assert_eq!(provider.provider(), "openai");
1464 }
1465
1466 #[test]
1467 fn test_minimax_factory_creates_provider_with_minimax_base_url() {
1468 let provider =
1469 OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
1470
1471 assert_eq!(provider.model(), "minimax-custom");
1472 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1473 assert_eq!(provider.provider(), "openai");
1474 }
1475
1476 #[test]
1477 fn test_minimax_m2_5_factory_creates_provider() {
1478 let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
1479
1480 assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
1481 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1482 assert_eq!(provider.provider(), "openai");
1483 }
1484
1485 #[test]
1490 fn test_model_constants_have_expected_values() {
1491 assert_eq!(MODEL_GPT54, "gpt-5.4");
1493 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1494 assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
1496 assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
1497 assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
1498 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1499 assert_eq!(MODEL_GPT5, "gpt-5");
1501 assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
1502 assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
1503 assert_eq!(MODEL_O3, "o3");
1505 assert_eq!(MODEL_O3_MINI, "o3-mini");
1506 assert_eq!(MODEL_O4_MINI, "o4-mini");
1507 assert_eq!(MODEL_O1, "o1");
1508 assert_eq!(MODEL_O1_MINI, "o1-mini");
1509 assert_eq!(MODEL_GPT41, "gpt-4.1");
1511 assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
1512 assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
1513 assert_eq!(MODEL_GPT4O, "gpt-4o");
1515 assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
1516 assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
1518 assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
1519 assert_eq!(MODEL_ZAI_GLM5, "glm-5");
1520 assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
1521 assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
1522 assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
1523 assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
1524 }
1525
1526 #[test]
1531 fn test_provider_is_cloneable() {
1532 let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
1533 let cloned = provider.clone();
1534
1535 assert_eq!(provider.model(), cloned.model());
1536 assert_eq!(provider.provider(), cloned.provider());
1537 assert_eq!(provider.base_url, cloned.base_url);
1538 }
1539
1540 #[test]
1545 fn test_api_role_serialization() {
1546 let system_role = ApiRole::System;
1547 let user_role = ApiRole::User;
1548 let assistant_role = ApiRole::Assistant;
1549 let tool_role = ApiRole::Tool;
1550
1551 assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
1552 assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
1553 assert_eq!(
1554 serde_json::to_string(&assistant_role).unwrap(),
1555 "\"assistant\""
1556 );
1557 assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
1558 }
1559
1560 #[test]
1561 fn test_api_message_serialization_simple() {
1562 let message = ApiMessage {
1563 role: ApiRole::User,
1564 content: Some("Hello, world!".to_string()),
1565 tool_calls: None,
1566 tool_call_id: None,
1567 };
1568
1569 let json = serde_json::to_string(&message).unwrap();
1570 assert!(json.contains("\"role\":\"user\""));
1571 assert!(json.contains("\"content\":\"Hello, world!\""));
1572 assert!(!json.contains("tool_calls"));
1574 assert!(!json.contains("tool_call_id"));
1575 }
1576
1577 #[test]
1578 fn test_api_message_serialization_with_tool_calls() {
1579 let message = ApiMessage {
1580 role: ApiRole::Assistant,
1581 content: Some("Let me help.".to_string()),
1582 tool_calls: Some(vec![ApiToolCall {
1583 id: "call_123".to_string(),
1584 r#type: "function".to_string(),
1585 function: ApiFunctionCall {
1586 name: "read_file".to_string(),
1587 arguments: "{\"path\": \"/test.txt\"}".to_string(),
1588 },
1589 }]),
1590 tool_call_id: None,
1591 };
1592
1593 let json = serde_json::to_string(&message).unwrap();
1594 assert!(json.contains("\"role\":\"assistant\""));
1595 assert!(json.contains("\"tool_calls\""));
1596 assert!(json.contains("\"id\":\"call_123\""));
1597 assert!(json.contains("\"type\":\"function\""));
1598 assert!(json.contains("\"name\":\"read_file\""));
1599 }
1600
1601 #[test]
1602 fn test_api_tool_message_serialization() {
1603 let message = ApiMessage {
1604 role: ApiRole::Tool,
1605 content: Some("File contents here".to_string()),
1606 tool_calls: None,
1607 tool_call_id: Some("call_123".to_string()),
1608 };
1609
1610 let json = serde_json::to_string(&message).unwrap();
1611 assert!(json.contains("\"role\":\"tool\""));
1612 assert!(json.contains("\"tool_call_id\":\"call_123\""));
1613 assert!(json.contains("\"content\":\"File contents here\""));
1614 }
1615
1616 #[test]
1617 fn test_api_tool_serialization() {
1618 let tool = ApiTool {
1619 r#type: "function".to_string(),
1620 function: ApiFunction {
1621 name: "test_tool".to_string(),
1622 description: "A test tool".to_string(),
1623 parameters: serde_json::json!({
1624 "type": "object",
1625 "properties": {
1626 "arg": {"type": "string"}
1627 }
1628 }),
1629 },
1630 };
1631
1632 let json = serde_json::to_string(&tool).unwrap();
1633 assert!(json.contains("\"type\":\"function\""));
1634 assert!(json.contains("\"name\":\"test_tool\""));
1635 assert!(json.contains("\"description\":\"A test tool\""));
1636 assert!(json.contains("\"parameters\""));
1637 }
1638
1639 #[test]
1644 fn test_api_response_deserialization() {
1645 let json = r#"{
1646 "id": "chatcmpl-123",
1647 "choices": [
1648 {
1649 "message": {
1650 "content": "Hello!"
1651 },
1652 "finish_reason": "stop"
1653 }
1654 ],
1655 "model": "gpt-4o",
1656 "usage": {
1657 "prompt_tokens": 100,
1658 "completion_tokens": 50
1659 }
1660 }"#;
1661
1662 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1663 assert_eq!(response.id, "chatcmpl-123");
1664 assert_eq!(response.model, "gpt-4o");
1665 assert_eq!(response.usage.prompt_tokens, 100);
1666 assert_eq!(response.usage.completion_tokens, 50);
1667 assert_eq!(response.choices.len(), 1);
1668 assert_eq!(
1669 response.choices[0].message.content,
1670 Some("Hello!".to_string())
1671 );
1672 }
1673
1674 #[test]
1675 fn test_api_response_with_tool_calls_deserialization() {
1676 let json = r#"{
1677 "id": "chatcmpl-456",
1678 "choices": [
1679 {
1680 "message": {
1681 "content": null,
1682 "tool_calls": [
1683 {
1684 "id": "call_abc",
1685 "type": "function",
1686 "function": {
1687 "name": "read_file",
1688 "arguments": "{\"path\": \"test.txt\"}"
1689 }
1690 }
1691 ]
1692 },
1693 "finish_reason": "tool_calls"
1694 }
1695 ],
1696 "model": "gpt-4o",
1697 "usage": {
1698 "prompt_tokens": 150,
1699 "completion_tokens": 30
1700 }
1701 }"#;
1702
1703 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1704 let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
1705 assert_eq!(tool_calls.len(), 1);
1706 assert_eq!(tool_calls[0].id, "call_abc");
1707 assert_eq!(tool_calls[0].function.name, "read_file");
1708 }
1709
1710 #[test]
1711 fn test_api_response_with_unknown_finish_reason_deserialization() {
1712 let json = r#"{
1713 "id": "chatcmpl-789",
1714 "choices": [
1715 {
1716 "message": {
1717 "content": "ok"
1718 },
1719 "finish_reason": "vendor_custom_reason"
1720 }
1721 ],
1722 "model": "glm-5",
1723 "usage": {
1724 "prompt_tokens": 10,
1725 "completion_tokens": 5
1726 }
1727 }"#;
1728
1729 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1730 assert_eq!(
1731 response.choices[0].finish_reason.as_deref(),
1732 Some("vendor_custom_reason")
1733 );
1734 assert_eq!(
1735 map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
1736 StopReason::StopSequence
1737 );
1738 }
1739
1740 #[test]
1741 fn test_map_finish_reason_covers_vendor_specific_values() {
1742 assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
1743 assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
1744 assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
1745 assert_eq!(
1746 map_finish_reason("content_filter"),
1747 StopReason::StopSequence
1748 );
1749 assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
1750 assert_eq!(map_finish_reason("network_error"), StopReason::StopSequence);
1751 assert_eq!(
1752 map_finish_reason("some_new_reason"),
1753 StopReason::StopSequence
1754 );
1755 }
1756
1757 #[test]
1762 fn test_build_api_messages_with_system() {
1763 let request = ChatRequest {
1764 system: "You are helpful.".to_string(),
1765 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1766 tools: None,
1767 max_tokens: 1024,
1768 max_tokens_explicit: true,
1769 session_id: None,
1770 cached_content: None,
1771 thinking: None,
1772 tool_choice: None,
1773 response_format: None,
1774 };
1775
1776 let api_messages = build_api_messages(&request);
1777 assert_eq!(api_messages.len(), 2);
1778 assert_eq!(api_messages[0].role, ApiRole::System);
1779 assert_eq!(
1780 api_messages[0].content,
1781 Some("You are helpful.".to_string())
1782 );
1783 assert_eq!(api_messages[1].role, ApiRole::User);
1784 assert_eq!(api_messages[1].content, Some("Hello".to_string()));
1785 }
1786
1787 #[test]
1788 fn test_build_api_messages_empty_system() {
1789 let request = ChatRequest {
1790 system: String::new(),
1791 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1792 tools: None,
1793 max_tokens: 1024,
1794 max_tokens_explicit: true,
1795 session_id: None,
1796 cached_content: None,
1797 thinking: None,
1798 tool_choice: None,
1799 response_format: None,
1800 };
1801
1802 let api_messages = build_api_messages(&request);
1803 assert_eq!(api_messages.len(), 1);
1804 assert_eq!(api_messages[0].role, ApiRole::User);
1805 }
1806
1807 #[test]
1808 fn test_convert_tool() {
1809 let tool = agent_sdk_foundation::llm::Tool {
1810 name: "test_tool".to_string(),
1811 description: "A test tool".to_string(),
1812 input_schema: serde_json::json!({"type": "object"}),
1813 display_name: "Test Tool".to_string(),
1814 tier: agent_sdk_foundation::ToolTier::Observe,
1815 };
1816
1817 let api_tool = convert_tool(tool);
1818 assert_eq!(api_tool.r#type, "function");
1819 assert_eq!(api_tool.function.name, "test_tool");
1820 assert_eq!(api_tool.function.description, "A test tool");
1821 }
1822
1823 #[test]
1824 fn test_build_content_blocks_text_only() {
1825 let message = ApiResponseMessage {
1826 content: Some("Hello!".to_string()),
1827 tool_calls: None,
1828 };
1829
1830 let blocks = build_content_blocks(&message);
1831 assert_eq!(blocks.len(), 1);
1832 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1833 }
1834
1835 #[test]
1836 fn test_build_content_blocks_with_tool_calls() {
1837 let message = ApiResponseMessage {
1838 content: Some("Let me help.".to_string()),
1839 tool_calls: Some(vec![ApiResponseToolCall {
1840 id: "call_123".to_string(),
1841 function: ApiResponseFunctionCall {
1842 name: "read_file".to_string(),
1843 arguments: "{\"path\": \"test.txt\"}".to_string(),
1844 },
1845 }]),
1846 };
1847
1848 let blocks = build_content_blocks(&message);
1849 assert_eq!(blocks.len(), 2);
1850 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
1851 assert!(
1852 matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
1853 );
1854 }
1855
1856 #[test]
1861 fn test_sse_chunk_text_delta_deserialization() {
1862 let json = r#"{
1863 "choices": [{
1864 "delta": {
1865 "content": "Hello"
1866 },
1867 "finish_reason": null
1868 }]
1869 }"#;
1870
1871 let chunk: SseChunk = serde_json::from_str(json).unwrap();
1872 assert_eq!(chunk.choices.len(), 1);
1873 assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
1874 assert!(chunk.choices[0].finish_reason.is_none());
1875 }
1876
1877 #[test]
1878 fn test_sse_chunk_tool_call_delta_deserialization() {
1879 let json = r#"{
1880 "choices": [{
1881 "delta": {
1882 "tool_calls": [{
1883 "index": 0,
1884 "id": "call_abc",
1885 "function": {
1886 "name": "read_file",
1887 "arguments": ""
1888 }
1889 }]
1890 },
1891 "finish_reason": null
1892 }]
1893 }"#;
1894
1895 let chunk: SseChunk = serde_json::from_str(json).unwrap();
1896 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
1897 assert_eq!(tool_calls.len(), 1);
1898 assert_eq!(tool_calls[0].index, 0);
1899 assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
1900 assert_eq!(
1901 tool_calls[0].function.as_ref().unwrap().name,
1902 Some("read_file".to_string())
1903 );
1904 }
1905
1906 #[test]
1907 fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
1908 let json = r#"{
1909 "choices": [{
1910 "delta": {
1911 "tool_calls": [{
1912 "index": 0,
1913 "function": {
1914 "arguments": "{\"path\":"
1915 }
1916 }]
1917 },
1918 "finish_reason": null
1919 }]
1920 }"#;
1921
1922 let chunk: SseChunk = serde_json::from_str(json).unwrap();
1923 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
1924 assert_eq!(tool_calls[0].id, None);
1925 assert_eq!(
1926 tool_calls[0].function.as_ref().unwrap().arguments,
1927 Some("{\"path\":".to_string())
1928 );
1929 }
1930
1931 #[test]
1932 fn test_sse_chunk_with_finish_reason_deserialization() {
1933 let json = r#"{
1934 "choices": [{
1935 "delta": {},
1936 "finish_reason": "stop"
1937 }]
1938 }"#;
1939
1940 let chunk: SseChunk = serde_json::from_str(json).unwrap();
1941 assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
1942 }
1943
1944 #[test]
1945 fn test_sse_chunk_with_usage_deserialization() {
1946 let json = r#"{
1947 "choices": [{
1948 "delta": {},
1949 "finish_reason": "stop"
1950 }],
1951 "usage": {
1952 "prompt_tokens": 100,
1953 "completion_tokens": 50
1954 }
1955 }"#;
1956
1957 let chunk: SseChunk = serde_json::from_str(json).unwrap();
1958 let usage = chunk.usage.unwrap();
1959 assert_eq!(usage.prompt_tokens, 100);
1960 assert_eq!(usage.completion_tokens, 50);
1961 }
1962
1963 #[test]
1964 fn test_sse_chunk_with_float_usage_deserialization() {
1965 let json = r#"{
1966 "choices": [{
1967 "delta": {},
1968 "finish_reason": "stop"
1969 }],
1970 "usage": {
1971 "prompt_tokens": 100.0,
1972 "completion_tokens": 50.0
1973 }
1974 }"#;
1975
1976 let chunk: SseChunk = serde_json::from_str(json).unwrap();
1977 let usage = chunk.usage.unwrap();
1978 assert_eq!(usage.prompt_tokens, 100);
1979 assert_eq!(usage.completion_tokens, 50);
1980 }
1981
1982 #[test]
1983 fn test_api_usage_deserializes_integer_compatible_numbers() {
1984 let json = r#"{
1985 "prompt_tokens": 42.0,
1986 "completion_tokens": 7
1987 }"#;
1988
1989 let usage: ApiUsage = serde_json::from_str(json).unwrap();
1990 assert_eq!(usage.prompt_tokens, 42);
1991 assert_eq!(usage.completion_tokens, 7);
1992 }
1993
1994 #[test]
1995 fn test_api_usage_deserializes_cached_tokens() {
1996 let json = r#"{
1997 "prompt_tokens": 42,
1998 "completion_tokens": 7,
1999 "prompt_tokens_details": {
2000 "cached_tokens": 10
2001 }
2002 }"#;
2003
2004 let usage: ApiUsage = serde_json::from_str(json).unwrap();
2005 assert_eq!(usage.prompt_tokens, 42);
2006 assert_eq!(usage.completion_tokens, 7);
2007 assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, 10);
2008 }
2009
2010 #[test]
2011 fn test_process_sse_data_maps_cached_tokens_to_cache_read_usage() {
2012 let results = process_sse_data(
2013 r#"{
2014 "choices": [],
2015 "usage": {
2016 "prompt_tokens": 42,
2017 "completion_tokens": 7,
2018 "prompt_tokens_details": {
2019 "cached_tokens": 10
2020 }
2021 }
2022 }"#,
2023 );
2024
2025 assert!(matches!(
2026 results.as_slice(),
2027 [SseProcessResult::Usage(Usage {
2028 input_tokens: 42,
2029 output_tokens: 7,
2030 cached_input_tokens: 10,
2031 cache_creation_input_tokens: 0,
2032 })]
2033 ));
2034 }
2035
2036 #[test]
2037 fn test_api_usage_rejects_fractional_numbers() {
2038 let json = r#"{
2039 "prompt_tokens": 42.5,
2040 "completion_tokens": 7
2041 }"#;
2042
2043 let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
2044 assert!(usage.is_err());
2045 }
2046
2047 #[test]
2048 fn test_use_max_tokens_alias_for_vendor_urls() {
2049 assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
2050 assert!(use_max_tokens_alias(BASE_URL_KIMI));
2051 assert!(use_max_tokens_alias(BASE_URL_ZAI));
2052 assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
2053 }
2054
2055 #[test]
2056 fn test_requires_responses_api_only_for_legacy_codex_model() {
2057 assert!(requires_responses_api(MODEL_GPT52_CODEX));
2058 assert!(!requires_responses_api(MODEL_GPT53_CODEX));
2059 assert!(!requires_responses_api(MODEL_GPT54));
2060 }
2061
2062 #[test]
2063 fn test_should_use_responses_api_for_official_agentic_requests() {
2064 let request = ChatRequest {
2065 system: String::new(),
2066 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2067 tools: Some(vec![agent_sdk_foundation::llm::Tool {
2068 name: "read_file".to_string(),
2069 description: "Read a file".to_string(),
2070 input_schema: serde_json::json!({"type": "object"}),
2071 display_name: "Read File".to_string(),
2072 tier: agent_sdk_foundation::ToolTier::Observe,
2073 }]),
2074 max_tokens: 1024,
2075 max_tokens_explicit: true,
2076 session_id: Some("thread-1".to_string()),
2077 cached_content: None,
2078 thinking: None,
2079 tool_choice: None,
2080 response_format: None,
2081 };
2082
2083 assert!(should_use_responses_api(
2084 DEFAULT_BASE_URL,
2085 MODEL_GPT54,
2086 &request
2087 ));
2088 assert!(!should_use_responses_api(
2089 BASE_URL_KIMI,
2090 MODEL_GPT54,
2091 &request
2092 ));
2093 }
2094
2095 #[test]
2096 fn test_build_api_reasoning_maps_enabled_budget_to_effort() {
2097 let reasoning = build_api_reasoning(Some(&ThinkingConfig::new(40_000))).unwrap();
2098 assert!(matches!(reasoning.effort, ReasoningEffort::XHigh));
2099 }
2100
2101 #[test]
2102 fn test_build_api_reasoning_uses_explicit_effort() {
2103 let reasoning =
2104 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::High))).unwrap();
2105 assert!(matches!(reasoning.effort, ReasoningEffort::High));
2106 }
2107
2108 #[test]
2109 fn test_build_api_reasoning_omits_adaptive_without_effort() {
2110 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
2111 }
2112
2113 #[test]
2114 fn test_openai_rejects_adaptive_thinking() {
2115 let provider = OpenAIProvider::gpt54("test-key".to_string());
2116 let error = provider
2117 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2118 .unwrap_err();
2119 assert!(
2120 error
2121 .to_string()
2122 .contains("adaptive thinking is not supported")
2123 );
2124 }
2125
2126 #[test]
2127 fn test_openai_non_reasoning_models_reject_thinking() {
2128 let provider = OpenAIProvider::gpt4o("test-key".to_string());
2129 let error = provider
2130 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
2131 .unwrap_err();
2132 assert!(error.to_string().contains("thinking is not supported"));
2133 }
2134
2135 #[test]
2136 fn test_request_serialization_openai_uses_max_completion_tokens_only() {
2137 let messages = vec![ApiMessage {
2138 role: ApiRole::User,
2139 content: Some("Hello".to_string()),
2140 tool_calls: None,
2141 tool_call_id: None,
2142 }];
2143
2144 let request = ApiChatRequest {
2145 model: "gpt-4o",
2146 messages: &messages,
2147 max_completion_tokens: Some(1024),
2148 max_tokens: None,
2149 tools: None,
2150 tool_choice: None,
2151 reasoning: None,
2152 response_format: None,
2153 };
2154
2155 let json = serde_json::to_string(&request).unwrap();
2156 assert!(json.contains("\"max_completion_tokens\":1024"));
2157 assert!(!json.contains("\"max_tokens\""));
2158 }
2159
2160 #[test]
2161 fn test_request_serialization_with_max_tokens_alias() {
2162 let messages = vec![ApiMessage {
2163 role: ApiRole::User,
2164 content: Some("Hello".to_string()),
2165 tool_calls: None,
2166 tool_call_id: None,
2167 }];
2168
2169 let request = ApiChatRequest {
2170 model: "glm-5",
2171 messages: &messages,
2172 max_completion_tokens: Some(1024),
2173 max_tokens: Some(1024),
2174 tools: None,
2175 tool_choice: None,
2176 reasoning: None,
2177 response_format: None,
2178 };
2179
2180 let json = serde_json::to_string(&request).unwrap();
2181 assert!(json.contains("\"max_completion_tokens\":1024"));
2182 assert!(json.contains("\"max_tokens\":1024"));
2183 }
2184
2185 #[test]
2186 fn test_streaming_request_serialization_openai_default() {
2187 let messages = vec![ApiMessage {
2188 role: ApiRole::User,
2189 content: Some("Hello".to_string()),
2190 tool_calls: None,
2191 tool_call_id: None,
2192 }];
2193
2194 let request = ApiChatRequestStreaming {
2195 model: "gpt-4o",
2196 messages: &messages,
2197 max_completion_tokens: Some(1024),
2198 max_tokens: None,
2199 tools: None,
2200 tool_choice: None,
2201 reasoning: None,
2202 response_format: None,
2203 stream_options: Some(ApiStreamOptions {
2204 include_usage: true,
2205 }),
2206 stream: true,
2207 };
2208
2209 let json = serde_json::to_string(&request).unwrap();
2210 assert!(json.contains("\"stream\":true"));
2211 assert!(json.contains("\"model\":\"gpt-4o\""));
2212 assert!(json.contains("\"max_completion_tokens\":1024"));
2213 assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
2214 assert!(!json.contains("\"max_tokens\""));
2215 }
2216
2217 #[test]
2218 fn test_streaming_request_serialization_with_max_tokens_alias() {
2219 let messages = vec![ApiMessage {
2220 role: ApiRole::User,
2221 content: Some("Hello".to_string()),
2222 tool_calls: None,
2223 tool_call_id: None,
2224 }];
2225
2226 let request = ApiChatRequestStreaming {
2227 model: "kimi-k2-thinking",
2228 messages: &messages,
2229 max_completion_tokens: Some(1024),
2230 max_tokens: Some(1024),
2231 tools: None,
2232 tool_choice: None,
2233 reasoning: None,
2234 response_format: None,
2235 stream_options: None,
2236 stream: true,
2237 };
2238
2239 let json = serde_json::to_string(&request).unwrap();
2240 assert!(json.contains("\"max_completion_tokens\":1024"));
2241 assert!(json.contains("\"max_tokens\":1024"));
2242 assert!(!json.contains("\"stream_options\""));
2243 }
2244
2245 #[test]
2246 fn test_request_serialization_includes_reasoning_when_present() {
2247 let messages = vec![ApiMessage {
2248 role: ApiRole::User,
2249 content: Some("Hello".to_string()),
2250 tool_calls: None,
2251 tool_call_id: None,
2252 }];
2253
2254 let request = ApiChatRequest {
2255 model: MODEL_GPT54,
2256 messages: &messages,
2257 max_completion_tokens: Some(1024),
2258 max_tokens: None,
2259 tools: None,
2260 tool_choice: None,
2261 reasoning: Some(ApiReasoning {
2262 effort: ReasoningEffort::High,
2263 }),
2264 response_format: None,
2265 };
2266
2267 let json = serde_json::to_string(&request).unwrap();
2268 assert!(json.contains("\"reasoning\":{\"effort\":\"high\"}"));
2269 }
2270
2271 #[test]
2272 fn test_response_format_serializes_as_json_schema() {
2273 let messages = vec![ApiMessage {
2274 role: ApiRole::User,
2275 content: Some("Hello".to_string()),
2276 tool_calls: None,
2277 tool_call_id: None,
2278 }];
2279
2280 let response_format = Some(ApiResponseFormat::from_response_format(
2281 &agent_sdk_foundation::llm::ResponseFormat::new(
2282 "person",
2283 serde_json::json!({"type": "object"}),
2284 ),
2285 ));
2286
2287 let request = ApiChatRequest {
2288 model: "gpt-4o",
2289 messages: &messages,
2290 max_completion_tokens: Some(1024),
2291 max_tokens: None,
2292 tools: None,
2293 tool_choice: None,
2294 reasoning: None,
2295 response_format,
2296 };
2297
2298 let json = serde_json::to_value(&request).unwrap();
2299 assert_eq!(json["response_format"]["type"], "json_schema");
2300 assert_eq!(json["response_format"]["json_schema"]["name"], "person");
2301 assert_eq!(json["response_format"]["json_schema"]["strict"], true);
2302 assert_eq!(
2303 json["response_format"]["json_schema"]["schema"]["type"],
2304 "object"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_response_format_omitted_when_absent() {
2310 let messages = vec![ApiMessage {
2311 role: ApiRole::User,
2312 content: Some("Hello".to_string()),
2313 tool_calls: None,
2314 tool_call_id: None,
2315 }];
2316
2317 let request = ApiChatRequest {
2318 model: "gpt-4o",
2319 messages: &messages,
2320 max_completion_tokens: Some(1024),
2321 max_tokens: None,
2322 tools: None,
2323 tool_choice: None,
2324 reasoning: None,
2325 response_format: None,
2326 };
2327
2328 let json = serde_json::to_string(&request).unwrap();
2329 assert!(!json.contains("response_format"));
2330 }
2331}