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 reasoning_text(message: &ApiResponseMessage) -> Option<&str> {
965 message
966 .reasoning_content
967 .as_deref()
968 .or(message.reasoning.as_deref())
969 .filter(|r| !r.is_empty())
970}
971
972fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
973 let mut blocks = Vec::new();
974
975 if let Some(content) = &message.content
977 && !content.is_empty()
978 {
979 blocks.push(ContentBlock::Text {
980 text: content.clone(),
981 });
982 } else if let Some(reasoning) = reasoning_text(message) {
983 blocks.push(ContentBlock::Thinking {
990 thinking: reasoning.to_owned(),
991 signature: None,
992 });
993 }
994
995 if let Some(tool_calls) = &message.tool_calls {
997 for tc in tool_calls {
998 let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
999 .unwrap_or_else(|_| serde_json::json!({}));
1000 blocks.push(ContentBlock::ToolUse {
1001 id: tc.id.clone(),
1002 name: tc.function.name.clone(),
1003 input,
1004 thought_signature: None,
1005 });
1006 }
1007 }
1008
1009 blocks
1010}
1011
1012#[derive(Serialize)]
1017struct ApiChatRequest<'a> {
1018 model: &'a str,
1019 messages: &'a [ApiMessage],
1020 #[serde(skip_serializing_if = "Option::is_none")]
1021 max_completion_tokens: Option<u32>,
1022 #[serde(skip_serializing_if = "Option::is_none")]
1023 max_tokens: Option<u32>,
1024 #[serde(skip_serializing_if = "Option::is_none")]
1025 tools: Option<&'a [ApiTool]>,
1026 #[serde(skip_serializing_if = "Option::is_none")]
1027 tool_choice: Option<ApiToolChoice>,
1028 #[serde(skip_serializing_if = "Option::is_none")]
1029 reasoning: Option<ApiReasoning>,
1030 #[serde(skip_serializing_if = "Option::is_none")]
1031 response_format: Option<ApiResponseFormat>,
1032}
1033
1034#[derive(Serialize)]
1035struct ApiChatRequestStreaming<'a> {
1036 model: &'a str,
1037 messages: &'a [ApiMessage],
1038 #[serde(skip_serializing_if = "Option::is_none")]
1039 max_completion_tokens: Option<u32>,
1040 #[serde(skip_serializing_if = "Option::is_none")]
1041 max_tokens: Option<u32>,
1042 #[serde(skip_serializing_if = "Option::is_none")]
1043 tools: Option<&'a [ApiTool]>,
1044 #[serde(skip_serializing_if = "Option::is_none")]
1045 tool_choice: Option<ApiToolChoice>,
1046 #[serde(skip_serializing_if = "Option::is_none")]
1047 reasoning: Option<ApiReasoning>,
1048 #[serde(skip_serializing_if = "Option::is_none")]
1049 response_format: Option<ApiResponseFormat>,
1050 #[serde(skip_serializing_if = "Option::is_none")]
1051 stream_options: Option<ApiStreamOptions>,
1052 stream: bool,
1053}
1054
1055#[derive(Serialize)]
1060#[serde(untagged)]
1061enum ApiToolChoice {
1062 String(String),
1063 Named {
1064 #[serde(rename = "type")]
1065 choice_type: String,
1066 function: ApiToolChoiceFunction,
1067 },
1068}
1069
1070#[derive(Serialize)]
1071struct ApiToolChoiceFunction {
1072 name: String,
1073}
1074
1075impl ApiToolChoice {
1076 fn from_tool_choice(tc: &agent_sdk_foundation::llm::ToolChoice) -> Self {
1077 match tc {
1078 agent_sdk_foundation::llm::ToolChoice::Auto => Self::String("auto".to_owned()),
1079 agent_sdk_foundation::llm::ToolChoice::Tool(name) => Self::Named {
1080 choice_type: "function".to_owned(),
1081 function: ApiToolChoiceFunction { name: name.clone() },
1082 },
1083 }
1084 }
1085}
1086
1087#[derive(Serialize)]
1091struct ApiResponseFormat {
1092 #[serde(rename = "type")]
1093 format_type: &'static str,
1094 json_schema: ApiJsonSchema,
1095}
1096
1097#[derive(Serialize)]
1098struct ApiJsonSchema {
1099 name: String,
1100 schema: serde_json::Value,
1101 strict: bool,
1102}
1103
1104impl ApiResponseFormat {
1105 fn from_response_format(rf: &agent_sdk_foundation::llm::ResponseFormat) -> Self {
1106 Self {
1107 format_type: "json_schema",
1108 json_schema: ApiJsonSchema {
1109 name: rf.name.clone(),
1110 schema: rf.schema.clone(),
1111 strict: rf.strict,
1112 },
1113 }
1114 }
1115}
1116
1117#[derive(Clone, Copy, Serialize)]
1118struct ApiStreamOptions {
1119 include_usage: bool,
1120}
1121
1122#[derive(Clone, Copy, Serialize)]
1123#[serde(rename_all = "lowercase")]
1124enum ReasoningEffort {
1125 Low,
1126 Medium,
1127 High,
1128 #[serde(rename = "xhigh")]
1129 XHigh,
1130}
1131
1132#[derive(Serialize)]
1133struct ApiReasoning {
1134 effort: ReasoningEffort,
1135}
1136
1137#[derive(Serialize)]
1138struct ApiMessage {
1139 role: ApiRole,
1140 #[serde(skip_serializing_if = "Option::is_none")]
1141 content: Option<String>,
1142 #[serde(skip_serializing_if = "Option::is_none")]
1143 tool_calls: Option<Vec<ApiToolCall>>,
1144 #[serde(skip_serializing_if = "Option::is_none")]
1145 tool_call_id: Option<String>,
1146}
1147
1148#[derive(Debug, Serialize, PartialEq, Eq)]
1149#[serde(rename_all = "lowercase")]
1150enum ApiRole {
1151 System,
1152 User,
1153 Assistant,
1154 Tool,
1155}
1156
1157#[derive(Serialize)]
1158struct ApiToolCall {
1159 id: String,
1160 r#type: String,
1161 function: ApiFunctionCall,
1162}
1163
1164#[derive(Serialize)]
1165struct ApiFunctionCall {
1166 name: String,
1167 arguments: String,
1168}
1169
1170#[derive(Serialize)]
1171struct ApiTool {
1172 r#type: String,
1173 function: ApiFunction,
1174}
1175
1176#[derive(Serialize)]
1177struct ApiFunction {
1178 name: String,
1179 description: String,
1180 parameters: serde_json::Value,
1181}
1182
1183#[derive(Deserialize)]
1188struct ApiChatResponse {
1189 id: String,
1190 choices: Vec<ApiChoice>,
1191 model: String,
1192 usage: ApiUsage,
1193}
1194
1195#[derive(Deserialize)]
1196struct ApiChoice {
1197 message: ApiResponseMessage,
1198 finish_reason: Option<String>,
1199}
1200
1201#[derive(Deserialize)]
1202struct ApiResponseMessage {
1203 content: Option<String>,
1204 tool_calls: Option<Vec<ApiResponseToolCall>>,
1205 #[serde(default)]
1208 reasoning_content: Option<String>,
1209 #[serde(default)]
1212 reasoning: Option<String>,
1213}
1214
1215#[derive(Deserialize)]
1216struct ApiResponseToolCall {
1217 id: String,
1218 function: ApiResponseFunctionCall,
1219}
1220
1221#[derive(Deserialize)]
1222struct ApiResponseFunctionCall {
1223 name: String,
1224 arguments: String,
1225}
1226
1227#[derive(Deserialize)]
1228struct ApiUsage {
1229 #[serde(deserialize_with = "deserialize_u32_from_number")]
1230 prompt_tokens: u32,
1231 #[serde(deserialize_with = "deserialize_u32_from_number")]
1232 completion_tokens: u32,
1233 #[serde(default)]
1234 prompt_tokens_details: Option<ApiPromptTokensDetails>,
1235}
1236
1237#[derive(Deserialize)]
1238struct ApiPromptTokensDetails {
1239 #[serde(default, deserialize_with = "deserialize_u32_from_number")]
1240 cached_tokens: u32,
1241}
1242
1243struct ToolCallAccumulator {
1249 id: String,
1250 name: String,
1251 arguments: String,
1252}
1253
1254#[derive(Deserialize)]
1256struct SseChunk {
1257 choices: Vec<SseChoice>,
1258 #[serde(default)]
1259 usage: Option<SseUsage>,
1260}
1261
1262#[derive(Deserialize)]
1263struct SseChoice {
1264 delta: SseDelta,
1265 finish_reason: Option<String>,
1266}
1267
1268#[derive(Deserialize)]
1269struct SseDelta {
1270 content: Option<String>,
1271 tool_calls: Option<Vec<SseToolCallDelta>>,
1272}
1273
1274#[derive(Deserialize)]
1275struct SseToolCallDelta {
1276 index: usize,
1277 id: Option<String>,
1278 function: Option<SseFunctionDelta>,
1279}
1280
1281#[derive(Deserialize)]
1282struct SseFunctionDelta {
1283 name: Option<String>,
1284 arguments: Option<String>,
1285}
1286
1287#[derive(Deserialize)]
1288struct SseUsage {
1289 #[serde(deserialize_with = "deserialize_u32_from_number")]
1290 prompt_tokens: u32,
1291 #[serde(deserialize_with = "deserialize_u32_from_number")]
1292 completion_tokens: u32,
1293 #[serde(default)]
1294 prompt_tokens_details: Option<ApiPromptTokensDetails>,
1295}
1296
1297fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
1298where
1299 D: serde::Deserializer<'de>,
1300{
1301 #[derive(Deserialize)]
1302 #[serde(untagged)]
1303 enum NumberLike {
1304 U64(u64),
1305 F64(f64),
1306 }
1307
1308 match NumberLike::deserialize(deserializer)? {
1309 NumberLike::U64(v) => u32::try_from(v)
1310 .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
1311 NumberLike::F64(v) => {
1312 if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
1313 v.to_string().parse::<u32>().map_err(|e| {
1314 D::Error::custom(format!(
1315 "failed to convert integer-compatible token count {v} to u32: {e}"
1316 ))
1317 })
1318 } else {
1319 Err(D::Error::custom(format!(
1320 "token count must be a non-negative integer-compatible number, got {v}"
1321 )))
1322 }
1323 }
1324 }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use super::*;
1330
1331 #[test]
1336 fn test_new_creates_provider_with_custom_model() {
1337 let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
1338
1339 assert_eq!(provider.model(), "custom-model");
1340 assert_eq!(provider.provider(), "openai");
1341 assert_eq!(provider.base_url, DEFAULT_BASE_URL);
1342 }
1343
1344 #[test]
1345 fn test_with_base_url_creates_provider_with_custom_url() {
1346 let provider = OpenAIProvider::with_base_url(
1347 "test-api-key".to_string(),
1348 "llama3".to_string(),
1349 "http://localhost:11434/v1".to_string(),
1350 );
1351
1352 assert_eq!(provider.model(), "llama3");
1353 assert_eq!(provider.base_url, "http://localhost:11434/v1");
1354 }
1355
1356 #[test]
1357 fn test_gpt4o_factory_creates_gpt4o_provider() {
1358 let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
1359
1360 assert_eq!(provider.model(), MODEL_GPT4O);
1361 assert_eq!(provider.provider(), "openai");
1362 }
1363
1364 #[test]
1365 fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
1366 let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
1367
1368 assert_eq!(provider.model(), MODEL_GPT4O_MINI);
1369 assert_eq!(provider.provider(), "openai");
1370 }
1371
1372 #[test]
1373 fn test_gpt52_thinking_factory_creates_provider() {
1374 let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
1375
1376 assert_eq!(provider.model(), MODEL_GPT52_THINKING);
1377 assert_eq!(provider.provider(), "openai");
1378 }
1379
1380 #[test]
1381 fn test_gpt54_factory_creates_provider() {
1382 let provider = OpenAIProvider::gpt54("test-api-key".to_string());
1383
1384 assert_eq!(provider.model(), MODEL_GPT54);
1385 assert_eq!(provider.provider(), "openai");
1386 }
1387
1388 #[test]
1389 fn test_gpt53_codex_factory_creates_provider() {
1390 let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
1391
1392 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1393 assert_eq!(provider.provider(), "openai");
1394 }
1395
1396 #[test]
1397 fn test_codex_factory_points_to_latest_codex_model() {
1398 let provider = OpenAIProvider::codex("test-api-key".to_string());
1399
1400 assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1401 assert_eq!(provider.provider(), "openai");
1402 }
1403
1404 #[test]
1405 fn test_gpt5_factory_creates_gpt5_provider() {
1406 let provider = OpenAIProvider::gpt5("test-api-key".to_string());
1407
1408 assert_eq!(provider.model(), MODEL_GPT5);
1409 assert_eq!(provider.provider(), "openai");
1410 }
1411
1412 #[test]
1413 fn test_gpt5_mini_factory_creates_provider() {
1414 let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
1415
1416 assert_eq!(provider.model(), MODEL_GPT5_MINI);
1417 assert_eq!(provider.provider(), "openai");
1418 }
1419
1420 #[test]
1421 fn test_o3_factory_creates_o3_provider() {
1422 let provider = OpenAIProvider::o3("test-api-key".to_string());
1423
1424 assert_eq!(provider.model(), MODEL_O3);
1425 assert_eq!(provider.provider(), "openai");
1426 }
1427
1428 #[test]
1429 fn test_o4_mini_factory_creates_o4_mini_provider() {
1430 let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
1431
1432 assert_eq!(provider.model(), MODEL_O4_MINI);
1433 assert_eq!(provider.provider(), "openai");
1434 }
1435
1436 #[test]
1437 fn test_o1_factory_creates_o1_provider() {
1438 let provider = OpenAIProvider::o1("test-api-key".to_string());
1439
1440 assert_eq!(provider.model(), MODEL_O1);
1441 assert_eq!(provider.provider(), "openai");
1442 }
1443
1444 #[test]
1445 fn test_gpt41_factory_creates_gpt41_provider() {
1446 let provider = OpenAIProvider::gpt41("test-api-key".to_string());
1447
1448 assert_eq!(provider.model(), MODEL_GPT41);
1449 assert_eq!(provider.provider(), "openai");
1450 }
1451
1452 #[test]
1453 fn test_kimi_factory_creates_provider_with_kimi_base_url() {
1454 let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
1455
1456 assert_eq!(provider.model(), "kimi-custom");
1457 assert_eq!(provider.base_url, BASE_URL_KIMI);
1458 assert_eq!(provider.provider(), "openai");
1459 }
1460
1461 #[test]
1462 fn test_kimi_k2_5_factory_creates_provider() {
1463 let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
1464
1465 assert_eq!(provider.model(), MODEL_KIMI_K2_5);
1466 assert_eq!(provider.base_url, BASE_URL_KIMI);
1467 assert_eq!(provider.provider(), "openai");
1468 }
1469
1470 #[test]
1471 fn test_kimi_k2_thinking_factory_creates_provider() {
1472 let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
1473
1474 assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
1475 assert_eq!(provider.base_url, BASE_URL_KIMI);
1476 assert_eq!(provider.provider(), "openai");
1477 }
1478
1479 #[test]
1480 fn test_zai_factory_creates_provider_with_zai_base_url() {
1481 let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
1482
1483 assert_eq!(provider.model(), "glm-custom");
1484 assert_eq!(provider.base_url, BASE_URL_ZAI);
1485 assert_eq!(provider.provider(), "openai");
1486 }
1487
1488 #[test]
1489 fn test_zai_glm5_factory_creates_provider() {
1490 let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
1491
1492 assert_eq!(provider.model(), MODEL_ZAI_GLM5);
1493 assert_eq!(provider.base_url, BASE_URL_ZAI);
1494 assert_eq!(provider.provider(), "openai");
1495 }
1496
1497 #[test]
1498 fn test_minimax_factory_creates_provider_with_minimax_base_url() {
1499 let provider =
1500 OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
1501
1502 assert_eq!(provider.model(), "minimax-custom");
1503 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1504 assert_eq!(provider.provider(), "openai");
1505 }
1506
1507 #[test]
1508 fn test_minimax_m2_5_factory_creates_provider() {
1509 let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
1510
1511 assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
1512 assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1513 assert_eq!(provider.provider(), "openai");
1514 }
1515
1516 #[test]
1521 fn test_model_constants_have_expected_values() {
1522 assert_eq!(MODEL_GPT54, "gpt-5.4");
1524 assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1525 assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
1527 assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
1528 assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
1529 assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1530 assert_eq!(MODEL_GPT5, "gpt-5");
1532 assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
1533 assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
1534 assert_eq!(MODEL_O3, "o3");
1536 assert_eq!(MODEL_O3_MINI, "o3-mini");
1537 assert_eq!(MODEL_O4_MINI, "o4-mini");
1538 assert_eq!(MODEL_O1, "o1");
1539 assert_eq!(MODEL_O1_MINI, "o1-mini");
1540 assert_eq!(MODEL_GPT41, "gpt-4.1");
1542 assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
1543 assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
1544 assert_eq!(MODEL_GPT4O, "gpt-4o");
1546 assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
1547 assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
1549 assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
1550 assert_eq!(MODEL_ZAI_GLM5, "glm-5");
1551 assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
1552 assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
1553 assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
1554 assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
1555 }
1556
1557 #[test]
1562 fn test_provider_is_cloneable() {
1563 let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
1564 let cloned = provider.clone();
1565
1566 assert_eq!(provider.model(), cloned.model());
1567 assert_eq!(provider.provider(), cloned.provider());
1568 assert_eq!(provider.base_url, cloned.base_url);
1569 }
1570
1571 #[test]
1576 fn test_api_role_serialization() {
1577 let system_role = ApiRole::System;
1578 let user_role = ApiRole::User;
1579 let assistant_role = ApiRole::Assistant;
1580 let tool_role = ApiRole::Tool;
1581
1582 assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
1583 assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
1584 assert_eq!(
1585 serde_json::to_string(&assistant_role).unwrap(),
1586 "\"assistant\""
1587 );
1588 assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
1589 }
1590
1591 #[test]
1592 fn test_api_message_serialization_simple() {
1593 let message = ApiMessage {
1594 role: ApiRole::User,
1595 content: Some("Hello, world!".to_string()),
1596 tool_calls: None,
1597 tool_call_id: None,
1598 };
1599
1600 let json = serde_json::to_string(&message).unwrap();
1601 assert!(json.contains("\"role\":\"user\""));
1602 assert!(json.contains("\"content\":\"Hello, world!\""));
1603 assert!(!json.contains("tool_calls"));
1605 assert!(!json.contains("tool_call_id"));
1606 }
1607
1608 #[test]
1609 fn test_api_message_serialization_with_tool_calls() {
1610 let message = ApiMessage {
1611 role: ApiRole::Assistant,
1612 content: Some("Let me help.".to_string()),
1613 tool_calls: Some(vec![ApiToolCall {
1614 id: "call_123".to_string(),
1615 r#type: "function".to_string(),
1616 function: ApiFunctionCall {
1617 name: "read_file".to_string(),
1618 arguments: "{\"path\": \"/test.txt\"}".to_string(),
1619 },
1620 }]),
1621 tool_call_id: None,
1622 };
1623
1624 let json = serde_json::to_string(&message).unwrap();
1625 assert!(json.contains("\"role\":\"assistant\""));
1626 assert!(json.contains("\"tool_calls\""));
1627 assert!(json.contains("\"id\":\"call_123\""));
1628 assert!(json.contains("\"type\":\"function\""));
1629 assert!(json.contains("\"name\":\"read_file\""));
1630 }
1631
1632 #[test]
1633 fn test_api_tool_message_serialization() {
1634 let message = ApiMessage {
1635 role: ApiRole::Tool,
1636 content: Some("File contents here".to_string()),
1637 tool_calls: None,
1638 tool_call_id: Some("call_123".to_string()),
1639 };
1640
1641 let json = serde_json::to_string(&message).unwrap();
1642 assert!(json.contains("\"role\":\"tool\""));
1643 assert!(json.contains("\"tool_call_id\":\"call_123\""));
1644 assert!(json.contains("\"content\":\"File contents here\""));
1645 }
1646
1647 #[test]
1648 fn test_api_tool_serialization() {
1649 let tool = ApiTool {
1650 r#type: "function".to_string(),
1651 function: ApiFunction {
1652 name: "test_tool".to_string(),
1653 description: "A test tool".to_string(),
1654 parameters: serde_json::json!({
1655 "type": "object",
1656 "properties": {
1657 "arg": {"type": "string"}
1658 }
1659 }),
1660 },
1661 };
1662
1663 let json = serde_json::to_string(&tool).unwrap();
1664 assert!(json.contains("\"type\":\"function\""));
1665 assert!(json.contains("\"name\":\"test_tool\""));
1666 assert!(json.contains("\"description\":\"A test tool\""));
1667 assert!(json.contains("\"parameters\""));
1668 }
1669
1670 #[test]
1675 fn test_api_response_deserialization() {
1676 let json = r#"{
1677 "id": "chatcmpl-123",
1678 "choices": [
1679 {
1680 "message": {
1681 "content": "Hello!"
1682 },
1683 "finish_reason": "stop"
1684 }
1685 ],
1686 "model": "gpt-4o",
1687 "usage": {
1688 "prompt_tokens": 100,
1689 "completion_tokens": 50
1690 }
1691 }"#;
1692
1693 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1694 assert_eq!(response.id, "chatcmpl-123");
1695 assert_eq!(response.model, "gpt-4o");
1696 assert_eq!(response.usage.prompt_tokens, 100);
1697 assert_eq!(response.usage.completion_tokens, 50);
1698 assert_eq!(response.choices.len(), 1);
1699 assert_eq!(
1700 response.choices[0].message.content,
1701 Some("Hello!".to_string())
1702 );
1703 }
1704
1705 #[test]
1706 fn test_api_response_with_tool_calls_deserialization() {
1707 let json = r#"{
1708 "id": "chatcmpl-456",
1709 "choices": [
1710 {
1711 "message": {
1712 "content": null,
1713 "tool_calls": [
1714 {
1715 "id": "call_abc",
1716 "type": "function",
1717 "function": {
1718 "name": "read_file",
1719 "arguments": "{\"path\": \"test.txt\"}"
1720 }
1721 }
1722 ]
1723 },
1724 "finish_reason": "tool_calls"
1725 }
1726 ],
1727 "model": "gpt-4o",
1728 "usage": {
1729 "prompt_tokens": 150,
1730 "completion_tokens": 30
1731 }
1732 }"#;
1733
1734 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1735 let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
1736 assert_eq!(tool_calls.len(), 1);
1737 assert_eq!(tool_calls[0].id, "call_abc");
1738 assert_eq!(tool_calls[0].function.name, "read_file");
1739 }
1740
1741 #[test]
1742 fn test_api_response_with_unknown_finish_reason_deserialization() {
1743 let json = r#"{
1744 "id": "chatcmpl-789",
1745 "choices": [
1746 {
1747 "message": {
1748 "content": "ok"
1749 },
1750 "finish_reason": "vendor_custom_reason"
1751 }
1752 ],
1753 "model": "glm-5",
1754 "usage": {
1755 "prompt_tokens": 10,
1756 "completion_tokens": 5
1757 }
1758 }"#;
1759
1760 let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1761 assert_eq!(
1762 response.choices[0].finish_reason.as_deref(),
1763 Some("vendor_custom_reason")
1764 );
1765 assert_eq!(
1766 map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
1767 StopReason::StopSequence
1768 );
1769 }
1770
1771 #[test]
1772 fn test_map_finish_reason_covers_vendor_specific_values() {
1773 assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
1774 assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
1775 assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
1776 assert_eq!(
1777 map_finish_reason("content_filter"),
1778 StopReason::StopSequence
1779 );
1780 assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
1781 assert_eq!(map_finish_reason("network_error"), StopReason::StopSequence);
1782 assert_eq!(
1783 map_finish_reason("some_new_reason"),
1784 StopReason::StopSequence
1785 );
1786 }
1787
1788 #[test]
1793 fn test_build_api_messages_with_system() {
1794 let request = ChatRequest {
1795 system: "You are helpful.".to_string(),
1796 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1797 tools: None,
1798 max_tokens: 1024,
1799 max_tokens_explicit: true,
1800 session_id: None,
1801 cached_content: None,
1802 thinking: None,
1803 tool_choice: None,
1804 response_format: None,
1805 };
1806
1807 let api_messages = build_api_messages(&request);
1808 assert_eq!(api_messages.len(), 2);
1809 assert_eq!(api_messages[0].role, ApiRole::System);
1810 assert_eq!(
1811 api_messages[0].content,
1812 Some("You are helpful.".to_string())
1813 );
1814 assert_eq!(api_messages[1].role, ApiRole::User);
1815 assert_eq!(api_messages[1].content, Some("Hello".to_string()));
1816 }
1817
1818 #[test]
1819 fn test_build_api_messages_empty_system() {
1820 let request = ChatRequest {
1821 system: String::new(),
1822 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1823 tools: None,
1824 max_tokens: 1024,
1825 max_tokens_explicit: true,
1826 session_id: None,
1827 cached_content: None,
1828 thinking: None,
1829 tool_choice: None,
1830 response_format: None,
1831 };
1832
1833 let api_messages = build_api_messages(&request);
1834 assert_eq!(api_messages.len(), 1);
1835 assert_eq!(api_messages[0].role, ApiRole::User);
1836 }
1837
1838 #[test]
1839 fn test_convert_tool() {
1840 let tool = agent_sdk_foundation::llm::Tool {
1841 name: "test_tool".to_string(),
1842 description: "A test tool".to_string(),
1843 input_schema: serde_json::json!({"type": "object"}),
1844 display_name: "Test Tool".to_string(),
1845 tier: agent_sdk_foundation::ToolTier::Observe,
1846 };
1847
1848 let api_tool = convert_tool(tool);
1849 assert_eq!(api_tool.r#type, "function");
1850 assert_eq!(api_tool.function.name, "test_tool");
1851 assert_eq!(api_tool.function.description, "A test tool");
1852 }
1853
1854 #[test]
1855 fn test_build_content_blocks_text_only() {
1856 let message = ApiResponseMessage {
1857 content: Some("Hello!".to_string()),
1858 tool_calls: None,
1859 reasoning_content: None,
1860 reasoning: None,
1861 };
1862
1863 let blocks = build_content_blocks(&message);
1864 assert_eq!(blocks.len(), 1);
1865 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1866 }
1867
1868 #[test]
1869 fn test_build_content_blocks_with_tool_calls() {
1870 let message = ApiResponseMessage {
1871 content: Some("Let me help.".to_string()),
1872 tool_calls: Some(vec![ApiResponseToolCall {
1873 id: "call_123".to_string(),
1874 function: ApiResponseFunctionCall {
1875 name: "read_file".to_string(),
1876 arguments: "{\"path\": \"test.txt\"}".to_string(),
1877 },
1878 }]),
1879 reasoning_content: None,
1880 reasoning: None,
1881 };
1882
1883 let blocks = build_content_blocks(&message);
1884 assert_eq!(blocks.len(), 2);
1885 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
1886 assert!(
1887 matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
1888 );
1889 }
1890
1891 #[test]
1892 fn test_build_content_blocks_falls_back_to_reasoning_content_when_content_empty() {
1893 let message = ApiResponseMessage {
1896 content: None,
1897 tool_calls: None,
1898 reasoning_content: Some("The answer is 42.".to_string()),
1899 reasoning: None,
1900 };
1901
1902 let blocks = build_content_blocks(&message);
1903 assert_eq!(blocks.len(), 1);
1904 assert!(
1905 matches!(&blocks[0], ContentBlock::Thinking { thinking, signature } if thinking == "The answer is 42." && signature.is_none())
1906 );
1907 }
1908
1909 #[test]
1910 fn test_build_content_blocks_falls_back_to_reasoning_field() {
1911 let message = ApiResponseMessage {
1913 content: Some(String::new()),
1914 tool_calls: None,
1915 reasoning_content: None,
1916 reasoning: Some("Considering options...".to_string()),
1917 };
1918
1919 let blocks = build_content_blocks(&message);
1920 assert_eq!(blocks.len(), 1);
1921 assert!(
1922 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "Considering options...")
1923 );
1924 }
1925
1926 #[test]
1927 fn test_build_content_blocks_prefers_reasoning_content_over_reasoning() {
1928 let message = ApiResponseMessage {
1929 content: None,
1930 tool_calls: None,
1931 reasoning_content: Some("primary".to_string()),
1932 reasoning: Some("secondary".to_string()),
1933 };
1934
1935 let blocks = build_content_blocks(&message);
1936 assert_eq!(blocks.len(), 1);
1937 assert!(
1938 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "primary")
1939 );
1940 }
1941
1942 #[test]
1943 fn test_build_content_blocks_does_not_add_reasoning_when_content_present() {
1944 let message = ApiResponseMessage {
1947 content: Some("Final answer.".to_string()),
1948 tool_calls: None,
1949 reasoning_content: Some("internal chain of thought".to_string()),
1950 reasoning: None,
1951 };
1952
1953 let blocks = build_content_blocks(&message);
1954 assert_eq!(blocks.len(), 1);
1955 assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Final answer."));
1956 }
1957
1958 #[test]
1959 fn test_build_content_blocks_reasoning_fallback_with_tool_calls() {
1960 let message = ApiResponseMessage {
1963 content: None,
1964 tool_calls: Some(vec![ApiResponseToolCall {
1965 id: "call_1".to_string(),
1966 function: ApiResponseFunctionCall {
1967 name: "search".to_string(),
1968 arguments: "{}".to_string(),
1969 },
1970 }]),
1971 reasoning_content: Some("I should search.".to_string()),
1972 reasoning: None,
1973 };
1974
1975 let blocks = build_content_blocks(&message);
1976 assert_eq!(blocks.len(), 2);
1977 assert!(
1978 matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "I should search.")
1979 );
1980 assert!(matches!(&blocks[1], ContentBlock::ToolUse { name, .. } if name == "search"));
1981 }
1982
1983 #[test]
1984 fn test_build_content_blocks_empty_message_yields_no_blocks() {
1985 let message = ApiResponseMessage {
1988 content: None,
1989 tool_calls: None,
1990 reasoning_content: None,
1991 reasoning: None,
1992 };
1993
1994 let blocks = build_content_blocks(&message);
1995 assert!(blocks.is_empty());
1996 }
1997
1998 #[test]
1999 fn test_api_response_message_deserializes_reasoning_content() {
2000 let json = r#"{
2001 "content": null,
2002 "reasoning_content": "step by step"
2003 }"#;
2004
2005 let message: ApiResponseMessage = serde_json::from_str(json).unwrap();
2006 assert_eq!(reasoning_text(&message), Some("step by step"));
2007 assert!(message.content.is_none());
2008 }
2009
2010 #[test]
2015 fn test_sse_chunk_text_delta_deserialization() {
2016 let json = r#"{
2017 "choices": [{
2018 "delta": {
2019 "content": "Hello"
2020 },
2021 "finish_reason": null
2022 }]
2023 }"#;
2024
2025 let chunk: SseChunk = serde_json::from_str(json).unwrap();
2026 assert_eq!(chunk.choices.len(), 1);
2027 assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
2028 assert!(chunk.choices[0].finish_reason.is_none());
2029 }
2030
2031 #[test]
2032 fn test_sse_chunk_tool_call_delta_deserialization() {
2033 let json = r#"{
2034 "choices": [{
2035 "delta": {
2036 "tool_calls": [{
2037 "index": 0,
2038 "id": "call_abc",
2039 "function": {
2040 "name": "read_file",
2041 "arguments": ""
2042 }
2043 }]
2044 },
2045 "finish_reason": null
2046 }]
2047 }"#;
2048
2049 let chunk: SseChunk = serde_json::from_str(json).unwrap();
2050 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
2051 assert_eq!(tool_calls.len(), 1);
2052 assert_eq!(tool_calls[0].index, 0);
2053 assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
2054 assert_eq!(
2055 tool_calls[0].function.as_ref().unwrap().name,
2056 Some("read_file".to_string())
2057 );
2058 }
2059
2060 #[test]
2061 fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
2062 let json = r#"{
2063 "choices": [{
2064 "delta": {
2065 "tool_calls": [{
2066 "index": 0,
2067 "function": {
2068 "arguments": "{\"path\":"
2069 }
2070 }]
2071 },
2072 "finish_reason": null
2073 }]
2074 }"#;
2075
2076 let chunk: SseChunk = serde_json::from_str(json).unwrap();
2077 let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
2078 assert_eq!(tool_calls[0].id, None);
2079 assert_eq!(
2080 tool_calls[0].function.as_ref().unwrap().arguments,
2081 Some("{\"path\":".to_string())
2082 );
2083 }
2084
2085 #[test]
2086 fn test_sse_chunk_with_finish_reason_deserialization() {
2087 let json = r#"{
2088 "choices": [{
2089 "delta": {},
2090 "finish_reason": "stop"
2091 }]
2092 }"#;
2093
2094 let chunk: SseChunk = serde_json::from_str(json).unwrap();
2095 assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
2096 }
2097
2098 #[test]
2099 fn test_sse_chunk_with_usage_deserialization() {
2100 let json = r#"{
2101 "choices": [{
2102 "delta": {},
2103 "finish_reason": "stop"
2104 }],
2105 "usage": {
2106 "prompt_tokens": 100,
2107 "completion_tokens": 50
2108 }
2109 }"#;
2110
2111 let chunk: SseChunk = serde_json::from_str(json).unwrap();
2112 let usage = chunk.usage.unwrap();
2113 assert_eq!(usage.prompt_tokens, 100);
2114 assert_eq!(usage.completion_tokens, 50);
2115 }
2116
2117 #[test]
2118 fn test_sse_chunk_with_float_usage_deserialization() {
2119 let json = r#"{
2120 "choices": [{
2121 "delta": {},
2122 "finish_reason": "stop"
2123 }],
2124 "usage": {
2125 "prompt_tokens": 100.0,
2126 "completion_tokens": 50.0
2127 }
2128 }"#;
2129
2130 let chunk: SseChunk = serde_json::from_str(json).unwrap();
2131 let usage = chunk.usage.unwrap();
2132 assert_eq!(usage.prompt_tokens, 100);
2133 assert_eq!(usage.completion_tokens, 50);
2134 }
2135
2136 #[test]
2137 fn test_api_usage_deserializes_integer_compatible_numbers() {
2138 let json = r#"{
2139 "prompt_tokens": 42.0,
2140 "completion_tokens": 7
2141 }"#;
2142
2143 let usage: ApiUsage = serde_json::from_str(json).unwrap();
2144 assert_eq!(usage.prompt_tokens, 42);
2145 assert_eq!(usage.completion_tokens, 7);
2146 }
2147
2148 #[test]
2149 fn test_api_usage_deserializes_cached_tokens() {
2150 let json = r#"{
2151 "prompt_tokens": 42,
2152 "completion_tokens": 7,
2153 "prompt_tokens_details": {
2154 "cached_tokens": 10
2155 }
2156 }"#;
2157
2158 let usage: ApiUsage = serde_json::from_str(json).unwrap();
2159 assert_eq!(usage.prompt_tokens, 42);
2160 assert_eq!(usage.completion_tokens, 7);
2161 assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, 10);
2162 }
2163
2164 #[test]
2165 fn test_process_sse_data_maps_cached_tokens_to_cache_read_usage() {
2166 let results = process_sse_data(
2167 r#"{
2168 "choices": [],
2169 "usage": {
2170 "prompt_tokens": 42,
2171 "completion_tokens": 7,
2172 "prompt_tokens_details": {
2173 "cached_tokens": 10
2174 }
2175 }
2176 }"#,
2177 );
2178
2179 assert!(matches!(
2180 results.as_slice(),
2181 [SseProcessResult::Usage(Usage {
2182 input_tokens: 42,
2183 output_tokens: 7,
2184 cached_input_tokens: 10,
2185 cache_creation_input_tokens: 0,
2186 })]
2187 ));
2188 }
2189
2190 #[test]
2191 fn test_api_usage_rejects_fractional_numbers() {
2192 let json = r#"{
2193 "prompt_tokens": 42.5,
2194 "completion_tokens": 7
2195 }"#;
2196
2197 let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
2198 assert!(usage.is_err());
2199 }
2200
2201 #[test]
2202 fn test_use_max_tokens_alias_for_vendor_urls() {
2203 assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
2204 assert!(use_max_tokens_alias(BASE_URL_KIMI));
2205 assert!(use_max_tokens_alias(BASE_URL_ZAI));
2206 assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
2207 }
2208
2209 #[test]
2210 fn test_requires_responses_api_only_for_legacy_codex_model() {
2211 assert!(requires_responses_api(MODEL_GPT52_CODEX));
2212 assert!(!requires_responses_api(MODEL_GPT53_CODEX));
2213 assert!(!requires_responses_api(MODEL_GPT54));
2214 }
2215
2216 #[test]
2217 fn test_should_use_responses_api_for_official_agentic_requests() {
2218 let request = ChatRequest {
2219 system: String::new(),
2220 messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2221 tools: Some(vec![agent_sdk_foundation::llm::Tool {
2222 name: "read_file".to_string(),
2223 description: "Read a file".to_string(),
2224 input_schema: serde_json::json!({"type": "object"}),
2225 display_name: "Read File".to_string(),
2226 tier: agent_sdk_foundation::ToolTier::Observe,
2227 }]),
2228 max_tokens: 1024,
2229 max_tokens_explicit: true,
2230 session_id: Some("thread-1".to_string()),
2231 cached_content: None,
2232 thinking: None,
2233 tool_choice: None,
2234 response_format: None,
2235 };
2236
2237 assert!(should_use_responses_api(
2238 DEFAULT_BASE_URL,
2239 MODEL_GPT54,
2240 &request
2241 ));
2242 assert!(!should_use_responses_api(
2243 BASE_URL_KIMI,
2244 MODEL_GPT54,
2245 &request
2246 ));
2247 }
2248
2249 #[test]
2250 fn test_build_api_reasoning_maps_enabled_budget_to_effort() {
2251 let reasoning = build_api_reasoning(Some(&ThinkingConfig::new(40_000))).unwrap();
2252 assert!(matches!(reasoning.effort, ReasoningEffort::XHigh));
2253 }
2254
2255 #[test]
2256 fn test_build_api_reasoning_uses_explicit_effort() {
2257 let reasoning =
2258 build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::High))).unwrap();
2259 assert!(matches!(reasoning.effort, ReasoningEffort::High));
2260 }
2261
2262 #[test]
2263 fn test_build_api_reasoning_omits_adaptive_without_effort() {
2264 assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
2265 }
2266
2267 #[test]
2268 fn test_openai_rejects_adaptive_thinking() {
2269 let provider = OpenAIProvider::gpt54("test-key".to_string());
2270 let error = provider
2271 .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2272 .unwrap_err();
2273 assert!(
2274 error
2275 .to_string()
2276 .contains("adaptive thinking is not supported")
2277 );
2278 }
2279
2280 #[test]
2281 fn test_openai_non_reasoning_models_reject_thinking() {
2282 let provider = OpenAIProvider::gpt4o("test-key".to_string());
2283 let error = provider
2284 .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
2285 .unwrap_err();
2286 assert!(error.to_string().contains("thinking is not supported"));
2287 }
2288
2289 #[test]
2290 fn test_request_serialization_openai_uses_max_completion_tokens_only() {
2291 let messages = vec![ApiMessage {
2292 role: ApiRole::User,
2293 content: Some("Hello".to_string()),
2294 tool_calls: None,
2295 tool_call_id: None,
2296 }];
2297
2298 let request = ApiChatRequest {
2299 model: "gpt-4o",
2300 messages: &messages,
2301 max_completion_tokens: Some(1024),
2302 max_tokens: None,
2303 tools: None,
2304 tool_choice: None,
2305 reasoning: None,
2306 response_format: None,
2307 };
2308
2309 let json = serde_json::to_string(&request).unwrap();
2310 assert!(json.contains("\"max_completion_tokens\":1024"));
2311 assert!(!json.contains("\"max_tokens\""));
2312 }
2313
2314 #[test]
2315 fn test_request_serialization_with_max_tokens_alias() {
2316 let messages = vec![ApiMessage {
2317 role: ApiRole::User,
2318 content: Some("Hello".to_string()),
2319 tool_calls: None,
2320 tool_call_id: None,
2321 }];
2322
2323 let request = ApiChatRequest {
2324 model: "glm-5",
2325 messages: &messages,
2326 max_completion_tokens: Some(1024),
2327 max_tokens: Some(1024),
2328 tools: None,
2329 tool_choice: None,
2330 reasoning: None,
2331 response_format: None,
2332 };
2333
2334 let json = serde_json::to_string(&request).unwrap();
2335 assert!(json.contains("\"max_completion_tokens\":1024"));
2336 assert!(json.contains("\"max_tokens\":1024"));
2337 }
2338
2339 #[test]
2340 fn test_streaming_request_serialization_openai_default() {
2341 let messages = vec![ApiMessage {
2342 role: ApiRole::User,
2343 content: Some("Hello".to_string()),
2344 tool_calls: None,
2345 tool_call_id: None,
2346 }];
2347
2348 let request = ApiChatRequestStreaming {
2349 model: "gpt-4o",
2350 messages: &messages,
2351 max_completion_tokens: Some(1024),
2352 max_tokens: None,
2353 tools: None,
2354 tool_choice: None,
2355 reasoning: None,
2356 response_format: None,
2357 stream_options: Some(ApiStreamOptions {
2358 include_usage: true,
2359 }),
2360 stream: true,
2361 };
2362
2363 let json = serde_json::to_string(&request).unwrap();
2364 assert!(json.contains("\"stream\":true"));
2365 assert!(json.contains("\"model\":\"gpt-4o\""));
2366 assert!(json.contains("\"max_completion_tokens\":1024"));
2367 assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
2368 assert!(!json.contains("\"max_tokens\""));
2369 }
2370
2371 #[test]
2372 fn test_streaming_request_serialization_with_max_tokens_alias() {
2373 let messages = vec![ApiMessage {
2374 role: ApiRole::User,
2375 content: Some("Hello".to_string()),
2376 tool_calls: None,
2377 tool_call_id: None,
2378 }];
2379
2380 let request = ApiChatRequestStreaming {
2381 model: "kimi-k2-thinking",
2382 messages: &messages,
2383 max_completion_tokens: Some(1024),
2384 max_tokens: Some(1024),
2385 tools: None,
2386 tool_choice: None,
2387 reasoning: None,
2388 response_format: None,
2389 stream_options: None,
2390 stream: true,
2391 };
2392
2393 let json = serde_json::to_string(&request).unwrap();
2394 assert!(json.contains("\"max_completion_tokens\":1024"));
2395 assert!(json.contains("\"max_tokens\":1024"));
2396 assert!(!json.contains("\"stream_options\""));
2397 }
2398
2399 #[test]
2400 fn test_request_serialization_includes_reasoning_when_present() {
2401 let messages = vec![ApiMessage {
2402 role: ApiRole::User,
2403 content: Some("Hello".to_string()),
2404 tool_calls: None,
2405 tool_call_id: None,
2406 }];
2407
2408 let request = ApiChatRequest {
2409 model: MODEL_GPT54,
2410 messages: &messages,
2411 max_completion_tokens: Some(1024),
2412 max_tokens: None,
2413 tools: None,
2414 tool_choice: None,
2415 reasoning: Some(ApiReasoning {
2416 effort: ReasoningEffort::High,
2417 }),
2418 response_format: None,
2419 };
2420
2421 let json = serde_json::to_string(&request).unwrap();
2422 assert!(json.contains("\"reasoning\":{\"effort\":\"high\"}"));
2423 }
2424
2425 #[test]
2426 fn test_response_format_serializes_as_json_schema() {
2427 let messages = vec![ApiMessage {
2428 role: ApiRole::User,
2429 content: Some("Hello".to_string()),
2430 tool_calls: None,
2431 tool_call_id: None,
2432 }];
2433
2434 let response_format = Some(ApiResponseFormat::from_response_format(
2435 &agent_sdk_foundation::llm::ResponseFormat::new(
2436 "person",
2437 serde_json::json!({"type": "object"}),
2438 ),
2439 ));
2440
2441 let request = ApiChatRequest {
2442 model: "gpt-4o",
2443 messages: &messages,
2444 max_completion_tokens: Some(1024),
2445 max_tokens: None,
2446 tools: None,
2447 tool_choice: None,
2448 reasoning: None,
2449 response_format,
2450 };
2451
2452 let json = serde_json::to_value(&request).unwrap();
2453 assert_eq!(json["response_format"]["type"], "json_schema");
2454 assert_eq!(json["response_format"]["json_schema"]["name"], "person");
2455 assert_eq!(json["response_format"]["json_schema"]["strict"], true);
2456 assert_eq!(
2457 json["response_format"]["json_schema"]["schema"]["type"],
2458 "object"
2459 );
2460 }
2461
2462 #[test]
2463 fn test_response_format_omitted_when_absent() {
2464 let messages = vec![ApiMessage {
2465 role: ApiRole::User,
2466 content: Some("Hello".to_string()),
2467 tool_calls: None,
2468 tool_call_id: None,
2469 }];
2470
2471 let request = ApiChatRequest {
2472 model: "gpt-4o",
2473 messages: &messages,
2474 max_completion_tokens: Some(1024),
2475 max_tokens: None,
2476 tools: None,
2477 tool_choice: None,
2478 reasoning: None,
2479 response_format: None,
2480 };
2481
2482 let json = serde_json::to_string(&request).unwrap();
2483 assert!(!json.contains("response_format"));
2484 }
2485}