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