1use crate::traits::{
2 ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
3 ModelProvider, TokenUsage, ToolCall as ProviderToolCall,
4};
5use async_trait::async_trait;
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use zeroclaw_api::tool::ToolSpec;
9
10pub(crate) const BASE_URL: &str = "https://api.openai.com/v1";
12
13pub struct OpenAiModelProvider {
14 alias: String,
16 base_url: String,
17 credential: Option<String>,
18 max_tokens: Option<u32>,
19}
20
21#[derive(Debug, Serialize)]
22struct ChatRequest {
23 model: String,
24 messages: Vec<Message>,
25 temperature: f64,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 max_tokens: Option<u32>,
28}
29
30#[derive(Debug, Serialize)]
31struct Message {
32 role: String,
33 content: String,
34}
35
36#[derive(Debug, Deserialize)]
37struct ChatResponse {
38 choices: Vec<Choice>,
39}
40
41#[derive(Debug, Deserialize)]
42struct Choice {
43 message: ResponseMessage,
44}
45
46#[derive(Debug, Deserialize)]
47struct ResponseMessage {
48 #[serde(default)]
49 content: Option<String>,
50 #[serde(default)]
52 reasoning_content: Option<String>,
53}
54
55impl ResponseMessage {
56 fn effective_content(&self) -> String {
57 match &self.content {
58 Some(c) if !c.is_empty() => c.clone(),
59 _ => self.reasoning_content.clone().unwrap_or_default(),
60 }
61 }
62}
63
64#[derive(Debug, Serialize)]
65struct NativeChatRequest {
66 model: String,
67 messages: Vec<NativeMessage>,
68 temperature: f64,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 tools: Option<Vec<NativeToolSpec>>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 tool_choice: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 max_tokens: Option<u32>,
75}
76
77#[derive(Debug, Serialize)]
78struct NativeMessage {
79 role: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 content: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 tool_call_id: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 tool_calls: Option<Vec<NativeToolCall>>,
86 #[serde(skip_serializing_if = "Option::is_none")]
89 reasoning_content: Option<String>,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93struct NativeToolSpec {
94 #[serde(rename = "type")]
95 kind: String,
96 function: NativeToolFunctionSpec,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100struct NativeToolFunctionSpec {
101 name: String,
102 description: String,
103 parameters: serde_json::Value,
104}
105
106fn parse_native_tool_spec(value: serde_json::Value) -> anyhow::Result<NativeToolSpec> {
107 let spec: NativeToolSpec = serde_json::from_value(value).map_err(|e| {
108 ::zeroclaw_log::record!(
109 WARN,
110 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
111 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
112 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
113 "openai: invalid tool spec"
114 );
115 anyhow::Error::msg(format!("Invalid OpenAI tool specification: {e}"))
116 })?;
117
118 if spec.kind != "function" {
119 anyhow::bail!(
120 "Invalid OpenAI tool specification: unsupported tool type '{}', expected 'function'",
121 spec.kind
122 );
123 }
124
125 Ok(spec)
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129struct NativeToolCall {
130 #[serde(skip_serializing_if = "Option::is_none")]
131 id: Option<String>,
132 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
133 kind: Option<String>,
134 function: NativeFunctionCall,
135}
136
137#[derive(Debug, Serialize, Deserialize)]
138struct NativeFunctionCall {
139 name: String,
140 arguments: String,
141}
142
143#[derive(Debug, Deserialize)]
144struct NativeChatResponse {
145 choices: Vec<NativeChoice>,
146 #[serde(default)]
147 usage: Option<UsageInfo>,
148}
149
150#[derive(Debug, Deserialize)]
151struct UsageInfo {
152 #[serde(default)]
153 prompt_tokens: Option<u64>,
154 #[serde(default)]
155 completion_tokens: Option<u64>,
156 #[serde(default)]
157 prompt_tokens_details: Option<PromptTokensDetails>,
158}
159
160#[derive(Debug, Deserialize)]
161struct PromptTokensDetails {
162 #[serde(default)]
163 cached_tokens: Option<u64>,
164}
165
166#[derive(Debug, Deserialize)]
167struct NativeChoice {
168 message: NativeResponseMessage,
169}
170
171#[derive(Debug, Deserialize)]
172struct NativeResponseMessage {
173 #[serde(default)]
174 content: Option<String>,
175 #[serde(default)]
177 reasoning_content: Option<String>,
178 #[serde(default)]
179 tool_calls: Option<Vec<NativeToolCall>>,
180}
181
182impl NativeResponseMessage {
183 fn effective_content(&self) -> Option<String> {
184 match &self.content {
185 Some(c) if !c.is_empty() => Some(c.clone()),
186 _ => self.reasoning_content.clone(),
187 }
188 }
189}
190
191impl OpenAiModelProvider {
192 pub fn new(alias: &str, credential: Option<&str>) -> Self {
193 Self::with_base_url(alias, None, credential)
194 }
195
196 pub fn with_base_url(alias: &str, base_url: Option<&str>, credential: Option<&str>) -> Self {
199 Self {
200 alias: alias.to_string(),
201 base_url: base_url
202 .map(|u| u.trim_end_matches('/').to_string())
203 .unwrap_or_else(|| BASE_URL.to_string()),
204 credential: credential.map(ToString::to_string),
205 max_tokens: None,
206 }
207 }
208
209 pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
211 self.max_tokens = max_tokens;
212 self
213 }
214
215 fn adjust_temperature_for_model(model: &str, requested_temperature: f64) -> f64 {
218 let requires_1_0 = matches!(
220 model,
221 "gpt-5"
222 | "gpt-5-2025-08-07"
223 | "gpt-5-mini"
224 | "gpt-5-mini-2025-08-07"
225 | "gpt-5-nano"
226 | "gpt-5-nano-2025-08-07"
227 | "gpt-5.1-chat-latest"
228 | "gpt-5.2-chat-latest"
229 | "gpt-5.3-chat-latest"
230 | "o1"
231 | "o1-2024-12-17"
232 | "o3"
233 | "o3-2025-04-16"
234 | "o3-mini"
235 | "o3-mini-2025-01-31"
236 | "o4-mini"
237 | "o4-mini-2025-04-16"
238 );
239
240 if requires_1_0 {
241 1.0
242 } else {
243 requested_temperature
244 }
245 }
246
247 fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
248 tools.map(|items| {
249 items
250 .iter()
251 .map(|tool| NativeToolSpec {
252 kind: "function".to_string(),
253 function: NativeToolFunctionSpec {
254 name: tool.name.clone(),
255 description: tool.description.clone(),
256 parameters: tool.parameters.clone(),
257 },
258 })
259 .collect()
260 })
261 }
262
263 fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
264 messages
265 .iter()
266 .map(|m| {
267 if m.role == "assistant"
268 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
269 && let Some(tool_calls_value) = value.get("tool_calls")
270 && let Ok(parsed_calls) =
271 serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
272 {
273 let tool_calls = parsed_calls
274 .into_iter()
275 .map(|tc| NativeToolCall {
276 id: Some(tc.id),
277 kind: Some("function".to_string()),
278 function: NativeFunctionCall {
279 name: tc.name,
280 arguments: tc.arguments,
281 },
282 })
283 .collect::<Vec<_>>();
284 let content = value
285 .get("content")
286 .and_then(serde_json::Value::as_str)
287 .map(ToString::to_string);
288 let reasoning_content = value
289 .get("reasoning_content")
290 .and_then(serde_json::Value::as_str)
291 .map(ToString::to_string);
292 return NativeMessage {
293 role: "assistant".to_string(),
294 content,
295 tool_call_id: None,
296 tool_calls: Some(tool_calls),
297 reasoning_content,
298 };
299 }
300
301 if m.role == "tool"
302 && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
303 {
304 let tool_call_id = value
305 .get("tool_call_id")
306 .and_then(serde_json::Value::as_str)
307 .map(ToString::to_string);
308 let content = value
309 .get("content")
310 .and_then(serde_json::Value::as_str)
311 .map(ToString::to_string);
312 return NativeMessage {
313 role: "tool".to_string(),
314 content,
315 tool_call_id,
316 tool_calls: None,
317 reasoning_content: None,
318 };
319 }
320
321 NativeMessage {
322 role: m.role.clone(),
323 content: Some(m.content.clone()),
324 tool_call_id: None,
325 tool_calls: None,
326 reasoning_content: None,
327 }
328 })
329 .collect()
330 }
331
332 fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
333 let text = message.effective_content();
334 let reasoning_content = message.reasoning_content.clone();
335 let tool_calls = message
336 .tool_calls
337 .unwrap_or_default()
338 .into_iter()
339 .map(|tc| ProviderToolCall {
340 id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
341 name: tc.function.name,
342 arguments: tc.function.arguments,
343 extra_content: None,
344 })
345 .collect::<Vec<_>>();
346
347 ProviderChatResponse {
348 text,
349 tool_calls,
350 usage: None,
351 reasoning_content,
352 }
353 }
354
355 fn http_client(&self) -> Client {
356 zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
357 "model_provider.openai",
358 120,
359 10,
360 )
361 }
362}
363
364#[async_trait]
365impl ModelProvider for OpenAiModelProvider {
366 fn default_base_url(&self) -> Option<&str> {
368 Some(BASE_URL)
369 }
370
371 async fn chat_with_system(
372 &self,
373 system_prompt: Option<&str>,
374 message: &str,
375 model: &str,
376 temperature: Option<f64>,
377 ) -> anyhow::Result<String> {
378 let credential = self.credential.as_ref().ok_or_else(|| {
379 ::zeroclaw_log::record!(
380 ERROR,
381 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
382 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
383 .with_attrs(::serde_json::json!({"missing": "credentials"})),
384 "openai: API key not configured"
385 );
386 anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
387 })?;
388
389 let temperature = temperature.unwrap_or(self.default_temperature());
390 let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature);
391
392 let mut messages = Vec::new();
393
394 if let Some(sys) = system_prompt {
395 messages.push(Message {
396 role: "system".to_string(),
397 content: sys.to_string(),
398 });
399 }
400
401 messages.push(Message {
402 role: "user".to_string(),
403 content: message.to_string(),
404 });
405
406 let request = ChatRequest {
407 model: model.to_string(),
408 messages,
409 temperature: adjusted_temperature,
410 max_tokens: self.max_tokens,
411 };
412
413 let response = self
414 .http_client()
415 .post(format!("{}/chat/completions", self.base_url))
416 .header("Authorization", format!("Bearer {credential}"))
417 .json(&request)
418 .send()
419 .await?;
420
421 if !response.status().is_success() {
422 return Err(super::api_error("OpenAI", response).await);
423 }
424
425 let chat_response: ChatResponse = response.json().await?;
426
427 chat_response
428 .choices
429 .into_iter()
430 .next()
431 .map(|c| c.message.effective_content())
432 .ok_or_else(|| {
433 ::zeroclaw_log::record!(
434 ERROR,
435 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
436 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
437 "openai: empty choices in response"
438 );
439 anyhow::Error::msg("No response from OpenAI")
440 })
441 }
442
443 async fn chat(
444 &self,
445 request: ProviderChatRequest<'_>,
446 model: &str,
447 temperature: Option<f64>,
448 ) -> anyhow::Result<ProviderChatResponse> {
449 let credential = self.credential.as_ref().ok_or_else(|| {
450 ::zeroclaw_log::record!(
451 ERROR,
452 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
453 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
454 .with_attrs(::serde_json::json!({"missing": "credentials"})),
455 "openai: API key not configured"
456 );
457 anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
458 })?;
459
460 let temperature = temperature.unwrap_or(self.default_temperature());
461 let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature);
462
463 let tools = Self::convert_tools(request.tools);
464 let native_request = NativeChatRequest {
465 model: model.to_string(),
466 messages: Self::convert_messages(request.messages),
467 temperature: adjusted_temperature,
468 tool_choice: tools.as_ref().map(|_| "auto".to_string()),
469 tools,
470 max_tokens: self.max_tokens,
471 };
472
473 let response = self
474 .http_client()
475 .post(format!("{}/chat/completions", self.base_url))
476 .header("Authorization", format!("Bearer {credential}"))
477 .json(&native_request)
478 .send()
479 .await?;
480
481 if !response.status().is_success() {
482 return Err(super::api_error("OpenAI", response).await);
483 }
484
485 let native_response: NativeChatResponse = response.json().await?;
486 let usage = native_response.usage.map(|u| TokenUsage {
487 input_tokens: u.prompt_tokens,
488 output_tokens: u.completion_tokens,
489 cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
490 });
491 let message = native_response
492 .choices
493 .into_iter()
494 .next()
495 .map(|c| c.message)
496 .ok_or_else(|| {
497 ::zeroclaw_log::record!(
498 ERROR,
499 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
500 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
501 "openai: empty choices in response"
502 );
503 anyhow::Error::msg("No response from OpenAI")
504 })?;
505 let mut result = Self::parse_native_response(message);
506 result.usage = usage;
507 Ok(result)
508 }
509
510 fn supports_native_tools(&self) -> bool {
511 true
512 }
513
514 async fn chat_with_tools(
515 &self,
516 messages: &[ChatMessage],
517 tools: &[serde_json::Value],
518 model: &str,
519 temperature: Option<f64>,
520 ) -> anyhow::Result<ProviderChatResponse> {
521 let credential = self.credential.as_ref().ok_or_else(|| {
522 ::zeroclaw_log::record!(
523 ERROR,
524 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
525 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
526 .with_attrs(::serde_json::json!({"missing": "credentials"})),
527 "openai: API key not configured"
528 );
529 anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
530 })?;
531
532 let temperature = temperature.unwrap_or(self.default_temperature());
533 let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature);
534
535 let native_tools: Option<Vec<NativeToolSpec>> = if tools.is_empty() {
536 None
537 } else {
538 Some(
539 tools
540 .iter()
541 .cloned()
542 .map(parse_native_tool_spec)
543 .collect::<Result<Vec<_>, _>>()?,
544 )
545 };
546
547 let native_request = NativeChatRequest {
548 model: model.to_string(),
549 messages: Self::convert_messages(messages),
550 temperature: adjusted_temperature,
551 tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
552 tools: native_tools,
553 max_tokens: self.max_tokens,
554 };
555
556 let response = self
557 .http_client()
558 .post(format!("{}/chat/completions", self.base_url))
559 .header("Authorization", format!("Bearer {credential}"))
560 .json(&native_request)
561 .send()
562 .await?;
563
564 if !response.status().is_success() {
565 return Err(super::api_error("OpenAI", response).await);
566 }
567
568 let native_response: NativeChatResponse = response.json().await?;
569 let usage = native_response.usage.map(|u| TokenUsage {
570 input_tokens: u.prompt_tokens,
571 output_tokens: u.completion_tokens,
572 cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
573 });
574 let message = native_response
575 .choices
576 .into_iter()
577 .next()
578 .map(|c| c.message)
579 .ok_or_else(|| {
580 ::zeroclaw_log::record!(
581 ERROR,
582 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
583 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
584 "openai: empty choices in response"
585 );
586 anyhow::Error::msg("No response from OpenAI")
587 })?;
588 let mut result = Self::parse_native_response(message);
589 result.usage = usage;
590 Ok(result)
591 }
592
593 async fn warmup(&self) -> anyhow::Result<()> {
594 if let Some(credential) = self.credential.as_ref() {
595 self.http_client()
596 .get(format!("{}/models", self.base_url))
597 .header("Authorization", format!("Bearer {credential}"))
598 .send()
599 .await?
600 .error_for_status()?;
601 }
602 Ok(())
603 }
604
605 async fn list_models(&self) -> anyhow::Result<Vec<String>> {
606 crate::models_dev::list_models_for("openai").await
609 }
610}
611
612impl ::zeroclaw_api::attribution::Attributable for OpenAiModelProvider {
613 fn role(&self) -> ::zeroclaw_api::attribution::Role {
614 ::zeroclaw_api::attribution::Role::Provider(
615 ::zeroclaw_api::attribution::ProviderKind::Model(
616 ::zeroclaw_api::attribution::ModelProviderKind::OpenAi,
617 ),
618 )
619 }
620 fn alias(&self) -> &str {
621 &self.alias
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn creates_with_key() {
631 let p = OpenAiModelProvider::new("test", Some("openai-test-credential"));
632 assert_eq!(p.credential.as_deref(), Some("openai-test-credential"));
633 }
634
635 #[test]
636 fn creates_without_key() {
637 let p = OpenAiModelProvider::new("test", None);
638 assert!(p.credential.is_none());
639 }
640
641 #[test]
642 fn creates_with_empty_key() {
643 let p = OpenAiModelProvider::new("test", Some(""));
644 assert_eq!(p.credential.as_deref(), Some(""));
645 }
646
647 #[tokio::test]
648 async fn chat_fails_without_key() {
649 let p = OpenAiModelProvider::new("test", None);
650 let result = p.chat_with_system(None, "hello", "gpt-4o", Some(0.7)).await;
651 assert!(result.is_err());
652 assert!(result.unwrap_err().to_string().contains("API key not set"));
653 }
654
655 #[tokio::test]
656 async fn chat_with_system_fails_without_key() {
657 let p = OpenAiModelProvider::new("test", None);
658 let result = p
659 .chat_with_system(Some("You are ZeroClaw"), "test", "gpt-4o", Some(0.5))
660 .await;
661 assert!(result.is_err());
662 }
663
664 #[test]
665 fn request_serializes_with_system_message() {
666 let req = ChatRequest {
667 model: "gpt-4o".to_string(),
668 messages: vec![
669 Message {
670 role: "system".to_string(),
671 content: "You are ZeroClaw".to_string(),
672 },
673 Message {
674 role: "user".to_string(),
675 content: "hello".to_string(),
676 },
677 ],
678 temperature: 0.7,
679 max_tokens: None,
680 };
681 let json = serde_json::to_string(&req).unwrap();
682 assert!(json.contains("\"role\":\"system\""));
683 assert!(json.contains("\"role\":\"user\""));
684 assert!(json.contains("gpt-4o"));
685 }
686
687 #[test]
688 fn request_serializes_without_system() {
689 let req = ChatRequest {
690 model: "gpt-4o".to_string(),
691 messages: vec![Message {
692 role: "user".to_string(),
693 content: "hello".to_string(),
694 }],
695 temperature: 0.0,
696 max_tokens: None,
697 };
698 let json = serde_json::to_string(&req).unwrap();
699 assert!(!json.contains("system"));
700 assert!(json.contains("\"temperature\":0.0"));
701 }
702
703 #[test]
704 fn response_deserializes_single_choice() {
705 let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#;
706 let resp: ChatResponse = serde_json::from_str(json).unwrap();
707 assert_eq!(resp.choices.len(), 1);
708 assert_eq!(resp.choices[0].message.effective_content(), "Hi!");
709 }
710
711 #[test]
712 fn response_deserializes_empty_choices() {
713 let json = r#"{"choices":[]}"#;
714 let resp: ChatResponse = serde_json::from_str(json).unwrap();
715 assert!(resp.choices.is_empty());
716 }
717
718 #[test]
719 fn response_deserializes_multiple_choices() {
720 let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#;
721 let resp: ChatResponse = serde_json::from_str(json).unwrap();
722 assert_eq!(resp.choices.len(), 2);
723 assert_eq!(resp.choices[0].message.effective_content(), "A");
724 }
725
726 #[test]
727 fn response_with_unicode() {
728 let json = r#"{"choices":[{"message":{"content":"Hello \u03A9"}}]}"#;
729 let resp: ChatResponse = serde_json::from_str(json).unwrap();
730 assert_eq!(
731 resp.choices[0].message.effective_content(),
732 "Hello \u{03A9}"
733 );
734 }
735
736 #[test]
737 fn response_with_long_content() {
738 let long = "x".repeat(100_000);
739 let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#);
740 let resp: ChatResponse = serde_json::from_str(&json).unwrap();
741 assert_eq!(
742 resp.choices[0].message.content.as_ref().unwrap().len(),
743 100_000
744 );
745 }
746
747 #[tokio::test]
748 async fn warmup_without_key_is_noop() {
749 let model_provider = OpenAiModelProvider::new("test", None);
750 let result = model_provider.warmup().await;
751 assert!(result.is_ok());
752 }
753
754 #[test]
759 fn reasoning_content_fallback_empty_content() {
760 let json = r#"{"choices":[{"message":{"content":"","reasoning_content":"Thinking..."}}]}"#;
761 let resp: ChatResponse = serde_json::from_str(json).unwrap();
762 assert_eq!(resp.choices[0].message.effective_content(), "Thinking...");
763 }
764
765 #[test]
766 fn reasoning_content_fallback_null_content() {
767 let json =
768 r#"{"choices":[{"message":{"content":null,"reasoning_content":"Thinking..."}}]}"#;
769 let resp: ChatResponse = serde_json::from_str(json).unwrap();
770 assert_eq!(resp.choices[0].message.effective_content(), "Thinking...");
771 }
772
773 #[test]
774 fn reasoning_content_not_used_when_content_present() {
775 let json = r#"{"choices":[{"message":{"content":"Hello","reasoning_content":"Ignored"}}]}"#;
776 let resp: ChatResponse = serde_json::from_str(json).unwrap();
777 assert_eq!(resp.choices[0].message.effective_content(), "Hello");
778 }
779
780 #[test]
781 fn native_response_reasoning_content_fallback() {
782 let json =
783 r#"{"choices":[{"message":{"content":"","reasoning_content":"Native thinking"}}]}"#;
784 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
785 let msg = &resp.choices[0].message;
786 assert_eq!(msg.effective_content(), Some("Native thinking".to_string()));
787 }
788
789 #[test]
790 fn native_response_reasoning_content_ignored_when_content_present() {
791 let json =
792 r#"{"choices":[{"message":{"content":"Real answer","reasoning_content":"Ignored"}}]}"#;
793 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
794 let msg = &resp.choices[0].message;
795 assert_eq!(msg.effective_content(), Some("Real answer".to_string()));
796 }
797
798 #[tokio::test]
799 async fn chat_with_tools_fails_without_key() {
800 let p = OpenAiModelProvider::new("test", None);
801 let messages = vec![ChatMessage::user("hello".to_string())];
802 let tools = vec![serde_json::json!({
803 "type": "function",
804 "function": {
805 "name": "shell",
806 "description": "Run a shell command",
807 "parameters": {
808 "type": "object",
809 "properties": {
810 "command": { "type": "string" }
811 },
812 "required": ["command"]
813 }
814 }
815 })];
816 let result = p
817 .chat_with_tools(&messages, &tools, "gpt-4o", Some(0.7))
818 .await;
819 assert!(result.is_err());
820 assert!(result.unwrap_err().to_string().contains("API key not set"));
821 }
822
823 #[tokio::test]
824 async fn chat_with_tools_rejects_invalid_tool_shape() {
825 let p = OpenAiModelProvider::new("test", Some("openai-test-credential"));
826 let messages = vec![ChatMessage::user("hello".to_string())];
827 let tools = vec![serde_json::json!({
828 "type": "function",
829 "function": {
830 "name": "shell",
831 "parameters": {
832 "type": "object",
833 "properties": {
834 "command": { "type": "string" }
835 },
836 "required": ["command"]
837 }
838 }
839 })];
840
841 let result = p
842 .chat_with_tools(&messages, &tools, "gpt-4o", Some(0.7))
843 .await;
844 assert!(result.is_err());
845 assert!(
846 result
847 .unwrap_err()
848 .to_string()
849 .contains("Invalid OpenAI tool specification")
850 );
851 }
852
853 #[test]
854 fn native_tool_spec_deserializes_from_openai_format() {
855 let json = serde_json::json!({
856 "type": "function",
857 "function": {
858 "name": "shell",
859 "description": "Run a shell command",
860 "parameters": {
861 "type": "object",
862 "properties": {
863 "command": { "type": "string" }
864 },
865 "required": ["command"]
866 }
867 }
868 });
869 let spec = parse_native_tool_spec(json).unwrap();
870 assert_eq!(spec.kind, "function");
871 assert_eq!(spec.function.name, "shell");
872 }
873
874 #[test]
875 fn native_response_parses_usage() {
876 let json = r#"{
877 "choices": [{"message": {"content": "Hello"}}],
878 "usage": {"prompt_tokens": 100, "completion_tokens": 50}
879 }"#;
880 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
881 let usage = resp.usage.unwrap();
882 assert_eq!(usage.prompt_tokens, Some(100));
883 assert_eq!(usage.completion_tokens, Some(50));
884 }
885
886 #[test]
887 fn native_response_parses_without_usage() {
888 let json = r#"{"choices": [{"message": {"content": "Hello"}}]}"#;
889 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
890 assert!(resp.usage.is_none());
891 }
892
893 #[test]
898 fn parse_native_response_captures_reasoning_content() {
899 let json = r#"{"choices":[{"message":{
900 "content":"answer",
901 "reasoning_content":"thinking step",
902 "tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{}"}}]
903 }}]}"#;
904 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
905 let message = resp.choices.into_iter().next().unwrap().message;
906 let parsed = OpenAiModelProvider::parse_native_response(message);
907 assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
908 assert_eq!(parsed.tool_calls.len(), 1);
909 }
910
911 #[test]
912 fn parse_native_response_none_reasoning_content_for_normal_model() {
913 let json = r#"{"choices":[{"message":{"content":"hello"}}]}"#;
914 let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
915 let message = resp.choices.into_iter().next().unwrap().message;
916 let parsed = OpenAiModelProvider::parse_native_response(message);
917 assert!(parsed.reasoning_content.is_none());
918 }
919
920 #[test]
921 fn convert_messages_round_trips_reasoning_content() {
922 use zeroclaw_api::model_provider::ChatMessage;
923
924 let history_json = serde_json::json!({
925 "content": "I will check",
926 "tool_calls": [{
927 "id": "tc_1",
928 "name": "shell",
929 "arguments": "{}"
930 }],
931 "reasoning_content": "Let me think..."
932 });
933
934 let messages = vec![ChatMessage::assistant(history_json.to_string())];
935 let native = OpenAiModelProvider::convert_messages(&messages);
936 assert_eq!(native.len(), 1);
937 assert_eq!(
938 native[0].reasoning_content.as_deref(),
939 Some("Let me think...")
940 );
941 }
942
943 #[test]
944 fn convert_messages_no_reasoning_content_when_absent() {
945 use zeroclaw_api::model_provider::ChatMessage;
946
947 let history_json = serde_json::json!({
948 "content": "I will check",
949 "tool_calls": [{
950 "id": "tc_1",
951 "name": "shell",
952 "arguments": "{}"
953 }]
954 });
955
956 let messages = vec![ChatMessage::assistant(history_json.to_string())];
957 let native = OpenAiModelProvider::convert_messages(&messages);
958 assert_eq!(native.len(), 1);
959 assert!(native[0].reasoning_content.is_none());
960 }
961
962 #[test]
963 fn native_message_omits_reasoning_content_when_none() {
964 let msg = NativeMessage {
965 role: "assistant".to_string(),
966 content: Some("hi".to_string()),
967 tool_call_id: None,
968 tool_calls: None,
969 reasoning_content: None,
970 };
971 let json = serde_json::to_string(&msg).unwrap();
972 assert!(!json.contains("reasoning_content"));
973 }
974
975 #[test]
976 fn native_message_includes_reasoning_content_when_some() {
977 let msg = NativeMessage {
978 role: "assistant".to_string(),
979 content: Some("hi".to_string()),
980 tool_call_id: None,
981 tool_calls: None,
982 reasoning_content: Some("thinking...".to_string()),
983 };
984 let json = serde_json::to_string(&msg).unwrap();
985 assert!(json.contains("reasoning_content"));
986 assert!(json.contains("thinking..."));
987 }
988
989 #[test]
994 fn adjust_temperature_for_o1_models() {
995 assert_eq!(
996 OpenAiModelProvider::adjust_temperature_for_model("o1", 0.7),
997 1.0
998 );
999 assert_eq!(
1000 OpenAiModelProvider::adjust_temperature_for_model("o1-2024-12-17", 0.5),
1001 1.0
1002 );
1003 }
1004
1005 #[test]
1006 fn adjust_temperature_for_o3_models() {
1007 assert_eq!(
1008 OpenAiModelProvider::adjust_temperature_for_model("o3", 0.7),
1009 1.0
1010 );
1011 assert_eq!(
1012 OpenAiModelProvider::adjust_temperature_for_model("o3-2025-04-16", 0.5),
1013 1.0
1014 );
1015 assert_eq!(
1016 OpenAiModelProvider::adjust_temperature_for_model("o3-mini", 0.3),
1017 1.0
1018 );
1019 assert_eq!(
1020 OpenAiModelProvider::adjust_temperature_for_model("o3-mini-2025-01-31", 0.8),
1021 1.0
1022 );
1023 }
1024
1025 #[test]
1026 fn adjust_temperature_for_o4_models() {
1027 assert_eq!(
1028 OpenAiModelProvider::adjust_temperature_for_model("o4-mini", 0.7),
1029 1.0
1030 );
1031 assert_eq!(
1032 OpenAiModelProvider::adjust_temperature_for_model("o4-mini-2025-04-16", 0.5),
1033 1.0
1034 );
1035 }
1036
1037 #[test]
1038 fn adjust_temperature_for_gpt5_models() {
1039 assert_eq!(
1040 OpenAiModelProvider::adjust_temperature_for_model("gpt-5", 0.7),
1041 1.0
1042 );
1043 assert_eq!(
1044 OpenAiModelProvider::adjust_temperature_for_model("gpt-5-2025-08-07", 0.5),
1045 1.0
1046 );
1047 assert_eq!(
1048 OpenAiModelProvider::adjust_temperature_for_model("gpt-5-mini", 0.3),
1049 1.0
1050 );
1051 assert_eq!(
1052 OpenAiModelProvider::adjust_temperature_for_model("gpt-5-mini-2025-08-07", 0.8),
1053 1.0
1054 );
1055 assert_eq!(
1056 OpenAiModelProvider::adjust_temperature_for_model("gpt-5-nano", 0.6),
1057 1.0
1058 );
1059 assert_eq!(
1060 OpenAiModelProvider::adjust_temperature_for_model("gpt-5-nano-2025-08-07", 0.4),
1061 1.0
1062 );
1063 }
1064
1065 #[test]
1066 fn adjust_temperature_for_gpt5_chat_latest_models() {
1067 assert_eq!(
1068 OpenAiModelProvider::adjust_temperature_for_model("gpt-5.1-chat-latest", 0.7),
1069 1.0
1070 );
1071 assert_eq!(
1072 OpenAiModelProvider::adjust_temperature_for_model("gpt-5.2-chat-latest", 0.5),
1073 1.0
1074 );
1075 assert_eq!(
1076 OpenAiModelProvider::adjust_temperature_for_model("gpt-5.3-chat-latest", 0.3),
1077 1.0
1078 );
1079 }
1080
1081 #[test]
1082 fn adjust_temperature_preserves_for_standard_models() {
1083 assert_eq!(
1084 OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 0.7),
1085 0.7
1086 );
1087 assert_eq!(
1088 OpenAiModelProvider::adjust_temperature_for_model("gpt-4-turbo", 0.5),
1089 0.5
1090 );
1091 assert_eq!(
1092 OpenAiModelProvider::adjust_temperature_for_model("gpt-3.5-turbo", 0.3),
1093 0.3
1094 );
1095 assert_eq!(
1096 OpenAiModelProvider::adjust_temperature_for_model("gpt-4", 1.0),
1097 1.0
1098 );
1099 }
1100
1101 #[test]
1102 fn adjust_temperature_handles_edge_cases() {
1103 assert_eq!(
1105 OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 0.0),
1106 0.0
1107 );
1108 assert_eq!(
1110 OpenAiModelProvider::adjust_temperature_for_model("o1", 1.0),
1111 1.0
1112 );
1113 assert_eq!(
1114 OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 1.0),
1115 1.0
1116 );
1117 }
1118}