Skip to main content

zeroclaw_providers/
openai.rs

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
10/// OpenAI's public API endpoint.
11pub(crate) const BASE_URL: &str = "https://api.openai.com/v1";
12
13pub struct OpenAiModelProvider {
14    /// `[model_providers.openai.<alias>]` config-key alias.
15    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    /// Reasoning/thinking models may return output in `reasoning_content`.
51    #[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    /// Raw reasoning content from thinking models; pass-through for model_providers
87    /// that require it in assistant tool-call history messages.
88    #[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    /// Reasoning/thinking models may return output in `reasoning_content`.
176    #[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    /// Create a model_provider with an optional custom base URL.
197    /// Falls back to `https://api.openai.com/v1` when `base_url` is `None`.
198    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    /// Set the maximum output tokens for API requests.
210    pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
211        self.max_tokens = max_tokens;
212        self
213    }
214
215    /// Adjust temperature for models that have specific requirements.
216    /// Some OpenAI models (like gpt-5-mini, o1, o3, etc) only accept temperature=1.0.
217    fn adjust_temperature_for_model(model: &str, requested_temperature: f64) -> f64 {
218        // Models that require temperature=1.0
219        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    // ── ModelProvider-family defaults ──
367    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        // OpenAI's /v1/models requires a credential. models.dev is the no-auth
607        // path onboard uses before the user has entered a key.
608        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    // ----------------------------------------------------------
755    // Reasoning model fallback tests (reasoning_content)
756    // ----------------------------------------------------------
757
758    #[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    // ═══════════════════════════════════════════════════════════════════════
894    // reasoning_content pass-through tests
895    // ═══════════════════════════════════════════════════════════════════════
896
897    #[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    // ═══════════════════════════════════════════════════════════════════════
990    // Temperature adjustment tests
991    // ═══════════════════════════════════════════════════════════════════════
992
993    #[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        // Temperature 0.0 should be preserved for standard models
1104        assert_eq!(
1105            OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 0.0),
1106            0.0
1107        );
1108        // Temperature 1.0 should be preserved for all models
1109        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}