Skip to main content

zeroclaw_providers/
openai.rs

1use crate::openai_codex::{
2    ResponsesStreamApiError, ResponsesStreamState, ResponsesToolSpec, append_utf8_stream_chunk,
3    build_responses_input, convert_tools, first_nonempty, process_sse_chunk,
4};
5use crate::stream_guard::AbortOnDrop;
6use crate::traits::{
7    ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
8    ModelProvider, ProviderCapabilities, StreamChunk, StreamError, StreamEvent, StreamOptions,
9    StreamResult, TokenUsage, ToolCall as ProviderToolCall,
10};
11use async_trait::async_trait;
12use futures_util::StreamExt;
13use futures_util::stream;
14use reqwest::Client;
15use serde::{Deserialize, Serialize};
16use zeroclaw_api::tool::ToolSpec;
17
18/// OpenAI's public API endpoint.
19pub(crate) const BASE_URL: &str = "https://api.openai.com/v1";
20
21/// Default endpoint for the OpenAI Responses API.
22const RESPONSES_URL: &str = "https://api.openai.com/v1/responses";
23
24pub struct OpenAiModelProvider {
25    /// `[providers.models.openai.<alias>]` config-key alias.
26    alias: String,
27    base_url: String,
28    credential: Option<String>,
29    max_tokens: Option<u32>,
30}
31
32#[derive(Debug, Serialize)]
33struct ChatRequest {
34    model: String,
35    messages: Vec<Message>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    temperature: Option<f64>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    max_tokens: Option<u32>,
40}
41
42#[derive(Debug, Serialize)]
43struct Message {
44    role: String,
45    content: String,
46}
47
48#[derive(Debug, Deserialize)]
49struct ChatResponse {
50    choices: Vec<Choice>,
51}
52
53#[derive(Debug, Deserialize)]
54struct Choice {
55    message: ResponseMessage,
56}
57
58#[derive(Debug, Deserialize)]
59struct ResponseMessage {
60    #[serde(default)]
61    content: Option<String>,
62    /// Reasoning/thinking models may return output in `reasoning_content`.
63    #[serde(default)]
64    reasoning_content: Option<String>,
65}
66
67impl ResponseMessage {
68    fn effective_content(&self) -> String {
69        match &self.content {
70            Some(c) if !c.is_empty() => c.clone(),
71            _ => self.reasoning_content.clone().unwrap_or_default(),
72        }
73    }
74}
75
76#[derive(Debug, Serialize)]
77struct NativeChatRequest {
78    model: String,
79    messages: Vec<NativeMessage>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    temperature: Option<f64>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    tools: Option<Vec<NativeToolSpec>>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    tool_choice: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    max_tokens: Option<u32>,
88}
89
90#[derive(Debug, Serialize)]
91struct NativeMessage {
92    role: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    content: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    tool_call_id: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    tool_calls: Option<Vec<NativeToolCall>>,
99    /// Raw reasoning content from thinking models; pass-through for model_providers
100    /// that require it in assistant tool-call history messages.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    reasoning_content: Option<String>,
103}
104
105#[derive(Debug, Serialize, Deserialize)]
106struct NativeToolSpec {
107    #[serde(rename = "type")]
108    kind: String,
109    function: NativeToolFunctionSpec,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
113struct NativeToolFunctionSpec {
114    name: String,
115    description: String,
116    parameters: serde_json::Value,
117}
118
119fn parse_native_tool_spec(value: serde_json::Value) -> anyhow::Result<NativeToolSpec> {
120    let spec: NativeToolSpec = serde_json::from_value(value).map_err(|e| {
121        ::zeroclaw_log::record!(
122            WARN,
123            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
124                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
125                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
126            "openai: invalid tool spec"
127        );
128        anyhow::Error::msg(format!("Invalid OpenAI tool specification: {e}"))
129    })?;
130
131    if spec.kind != "function" {
132        anyhow::bail!(
133            "Invalid OpenAI tool specification: unsupported tool type '{}', expected 'function'",
134            spec.kind
135        );
136    }
137
138    Ok(spec)
139}
140
141#[derive(Debug, Serialize, Deserialize)]
142struct NativeToolCall {
143    #[serde(skip_serializing_if = "Option::is_none")]
144    id: Option<String>,
145    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
146    kind: Option<String>,
147    function: NativeFunctionCall,
148}
149
150#[derive(Debug, Serialize, Deserialize)]
151struct NativeFunctionCall {
152    name: String,
153    arguments: String,
154}
155
156#[derive(Debug, Deserialize)]
157struct NativeChatResponse {
158    choices: Vec<NativeChoice>,
159    #[serde(default)]
160    usage: Option<UsageInfo>,
161}
162
163#[derive(Debug, Deserialize)]
164struct UsageInfo {
165    #[serde(default)]
166    prompt_tokens: Option<u64>,
167    #[serde(default)]
168    completion_tokens: Option<u64>,
169    #[serde(default)]
170    prompt_tokens_details: Option<PromptTokensDetails>,
171}
172
173#[derive(Debug, Deserialize)]
174struct PromptTokensDetails {
175    #[serde(default)]
176    cached_tokens: Option<u64>,
177}
178
179#[derive(Debug, Deserialize)]
180struct NativeChoice {
181    message: NativeResponseMessage,
182}
183
184#[derive(Debug, Deserialize)]
185struct NativeResponseMessage {
186    #[serde(default)]
187    content: Option<String>,
188    /// Reasoning/thinking models may return output in `reasoning_content`.
189    #[serde(default)]
190    reasoning_content: Option<String>,
191    #[serde(default)]
192    tool_calls: Option<Vec<NativeToolCall>>,
193}
194
195impl NativeResponseMessage {
196    fn effective_content(&self) -> Option<String> {
197        match &self.content {
198            Some(c) if !c.is_empty() => Some(c.clone()),
199            _ => self.reasoning_content.clone(),
200        }
201    }
202}
203
204impl OpenAiModelProvider {
205    pub fn new(alias: &str, credential: Option<&str>) -> Self {
206        Self::with_base_url(alias, None, credential)
207    }
208
209    /// Create a model_provider with an optional custom base URL.
210    /// Falls back to `https://api.openai.com/v1` when `base_url` is `None`.
211    pub fn with_base_url(alias: &str, base_url: Option<&str>, credential: Option<&str>) -> Self {
212        Self {
213            alias: alias.to_string(),
214            base_url: base_url
215                .map(|u| u.trim_end_matches('/').to_string())
216                .unwrap_or_else(|| BASE_URL.to_string()),
217            credential: credential.map(ToString::to_string),
218            max_tokens: None,
219        }
220    }
221
222    /// Set the maximum output tokens for API requests.
223    pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
224        self.max_tokens = max_tokens;
225        self
226    }
227
228    /// Adjust temperature for models that have specific requirements.
229    /// Some OpenAI models (like gpt-5-mini, o1, o3, etc) only accept temperature=1.0.
230    fn adjust_temperature_for_model(model: &str, requested_temperature: f64) -> f64 {
231        // Models that require temperature=1.0
232        let requires_1_0 = matches!(
233            model,
234            "gpt-5"
235                | "gpt-5-2025-08-07"
236                | "gpt-5-mini"
237                | "gpt-5-mini-2025-08-07"
238                | "gpt-5-nano"
239                | "gpt-5-nano-2025-08-07"
240                | "gpt-5.1-chat-latest"
241                | "gpt-5.2-chat-latest"
242                | "gpt-5.3-chat-latest"
243                | "o1"
244                | "o1-2024-12-17"
245                | "o1-mini"
246                | "o1-mini-2024-09-12"
247                | "o3"
248                | "o3-2025-04-16"
249                | "o3-mini"
250                | "o3-mini-2025-01-31"
251                | "o4-mini"
252                | "o4-mini-2025-04-16"
253        );
254
255        if requires_1_0 {
256            1.0
257        } else {
258            requested_temperature
259        }
260    }
261
262    fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
263        tools.map(|items| {
264            items
265                .iter()
266                .map(|tool| NativeToolSpec {
267                    kind: "function".to_string(),
268                    function: NativeToolFunctionSpec {
269                        name: tool.name.clone(),
270                        description: tool.description.clone(),
271                        parameters: tool.parameters.clone(),
272                    },
273                })
274                .collect()
275        })
276    }
277
278    fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
279        messages
280            .iter()
281            .map(|m| {
282                if m.role == "assistant"
283                    && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
284                    && let Some(tool_calls_value) = value.get("tool_calls")
285                    && let Ok(parsed_calls) =
286                        serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
287                {
288                    let tool_calls = parsed_calls
289                        .into_iter()
290                        .map(|tc| NativeToolCall {
291                            id: Some(tc.id),
292                            kind: Some("function".to_string()),
293                            function: NativeFunctionCall {
294                                name: tc.name,
295                                arguments: tc.arguments,
296                            },
297                        })
298                        .collect::<Vec<_>>();
299                    let content = value
300                        .get("content")
301                        .and_then(serde_json::Value::as_str)
302                        .map(ToString::to_string);
303                    let reasoning_content = value
304                        .get("reasoning_content")
305                        .and_then(serde_json::Value::as_str)
306                        .map(ToString::to_string);
307                    return NativeMessage {
308                        role: "assistant".to_string(),
309                        content,
310                        tool_call_id: None,
311                        tool_calls: Some(tool_calls),
312                        reasoning_content,
313                    };
314                }
315
316                if m.role == "tool"
317                    && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
318                {
319                    let tool_call_id = value
320                        .get("tool_call_id")
321                        .and_then(serde_json::Value::as_str)
322                        .map(ToString::to_string);
323                    let content = value
324                        .get("content")
325                        .and_then(serde_json::Value::as_str)
326                        .map(ToString::to_string);
327                    return NativeMessage {
328                        role: "tool".to_string(),
329                        content,
330                        tool_call_id,
331                        tool_calls: None,
332                        reasoning_content: None,
333                    };
334                }
335
336                NativeMessage {
337                    role: m.role.clone(),
338                    content: Some(m.content.clone()),
339                    tool_call_id: None,
340                    tool_calls: None,
341                    reasoning_content: None,
342                }
343            })
344            .collect()
345    }
346
347    fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
348        let text = message.effective_content();
349        let reasoning_content = message.reasoning_content.clone();
350        let tool_calls = message
351            .tool_calls
352            .unwrap_or_default()
353            .into_iter()
354            .map(|tc| ProviderToolCall {
355                id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
356                name: tc.function.name,
357                arguments: tc.function.arguments,
358                extra_content: None,
359            })
360            .collect::<Vec<_>>();
361
362        ProviderChatResponse {
363            text,
364            tool_calls,
365            usage: None,
366            reasoning_content,
367        }
368    }
369
370    fn http_client(&self) -> Client {
371        zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
372            "model_provider.openai",
373            120,
374            10,
375        )
376    }
377}
378
379#[async_trait]
380impl ModelProvider for OpenAiModelProvider {
381    // ── ModelProvider-family defaults ──
382    fn default_base_url(&self) -> Option<&str> {
383        Some(BASE_URL)
384    }
385
386    async fn chat_with_system(
387        &self,
388        system_prompt: Option<&str>,
389        message: &str,
390        model: &str,
391        temperature: Option<f64>,
392    ) -> anyhow::Result<String> {
393        let credential = self.credential.as_ref().ok_or_else(|| {
394            ::zeroclaw_log::record!(
395                ERROR,
396                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
397                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
398                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
399                "openai: API key not configured"
400            );
401            anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
402        })?;
403
404        let adjusted_temperature =
405            temperature.map(|t| Self::adjust_temperature_for_model(model, t));
406
407        let mut messages = Vec::new();
408
409        if let Some(sys) = system_prompt {
410            messages.push(Message {
411                role: "system".to_string(),
412                content: sys.to_string(),
413            });
414        }
415
416        messages.push(Message {
417            role: "user".to_string(),
418            content: message.to_string(),
419        });
420
421        let request = ChatRequest {
422            model: model.to_string(),
423            messages,
424            temperature: adjusted_temperature,
425            max_tokens: self.max_tokens,
426        };
427
428        let response = self
429            .http_client()
430            .post(format!("{}/chat/completions", self.base_url))
431            .header("Authorization", format!("Bearer {credential}"))
432            .json(&request)
433            .send()
434            .await?;
435
436        if !response.status().is_success() {
437            return Err(super::api_error("OpenAI", response).await);
438        }
439
440        let chat_response: ChatResponse = response.json().await?;
441
442        chat_response
443            .choices
444            .into_iter()
445            .next()
446            .map(|c| c.message.effective_content())
447            .ok_or_else(|| {
448                ::zeroclaw_log::record!(
449                    ERROR,
450                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
451                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
452                    "openai: empty choices in response"
453                );
454                anyhow::Error::msg("No response from OpenAI")
455            })
456    }
457
458    async fn chat(
459        &self,
460        request: ProviderChatRequest<'_>,
461        model: &str,
462        temperature: Option<f64>,
463    ) -> anyhow::Result<ProviderChatResponse> {
464        let credential = self.credential.as_ref().ok_or_else(|| {
465            ::zeroclaw_log::record!(
466                ERROR,
467                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
468                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
469                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
470                "openai: API key not configured"
471            );
472            anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
473        })?;
474
475        let adjusted_temperature =
476            temperature.map(|t| Self::adjust_temperature_for_model(model, t));
477
478        let tools = Self::convert_tools(request.tools);
479        let native_request = NativeChatRequest {
480            model: model.to_string(),
481            messages: Self::convert_messages(request.messages),
482            temperature: adjusted_temperature,
483            tool_choice: tools.as_ref().map(|_| "auto".to_string()),
484            tools,
485            max_tokens: self.max_tokens,
486        };
487
488        let response = self
489            .http_client()
490            .post(format!("{}/chat/completions", self.base_url))
491            .header("Authorization", format!("Bearer {credential}"))
492            .json(&native_request)
493            .send()
494            .await?;
495
496        if !response.status().is_success() {
497            return Err(super::api_error("OpenAI", response).await);
498        }
499
500        let native_response: NativeChatResponse = response.json().await?;
501        let usage = native_response.usage.map(|u| TokenUsage {
502            input_tokens: u.prompt_tokens,
503            output_tokens: u.completion_tokens,
504            cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
505        });
506        let message = native_response
507            .choices
508            .into_iter()
509            .next()
510            .map(|c| c.message)
511            .ok_or_else(|| {
512                ::zeroclaw_log::record!(
513                    ERROR,
514                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
515                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
516                    "openai: empty choices in response"
517                );
518                anyhow::Error::msg("No response from OpenAI")
519            })?;
520        let mut result = Self::parse_native_response(message);
521        result.usage = usage;
522        Ok(result)
523    }
524
525    fn supports_native_tools(&self) -> bool {
526        true
527    }
528
529    async fn chat_with_tools(
530        &self,
531        messages: &[ChatMessage],
532        tools: &[serde_json::Value],
533        model: &str,
534        temperature: Option<f64>,
535    ) -> anyhow::Result<ProviderChatResponse> {
536        let credential = self.credential.as_ref().ok_or_else(|| {
537            ::zeroclaw_log::record!(
538                ERROR,
539                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
540                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
541                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
542                "openai: API key not configured"
543            );
544            anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
545        })?;
546
547        let adjusted_temperature =
548            temperature.map(|t| Self::adjust_temperature_for_model(model, t));
549
550        let native_tools: Option<Vec<NativeToolSpec>> = if tools.is_empty() {
551            None
552        } else {
553            Some(
554                tools
555                    .iter()
556                    .cloned()
557                    .map(parse_native_tool_spec)
558                    .collect::<Result<Vec<_>, _>>()?,
559            )
560        };
561
562        let native_request = NativeChatRequest {
563            model: model.to_string(),
564            messages: Self::convert_messages(messages),
565            temperature: adjusted_temperature,
566            tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
567            tools: native_tools,
568            max_tokens: self.max_tokens,
569        };
570
571        let response = self
572            .http_client()
573            .post(format!("{}/chat/completions", self.base_url))
574            .header("Authorization", format!("Bearer {credential}"))
575            .json(&native_request)
576            .send()
577            .await?;
578
579        if !response.status().is_success() {
580            return Err(super::api_error("OpenAI", response).await);
581        }
582
583        let native_response: NativeChatResponse = response.json().await?;
584        let usage = native_response.usage.map(|u| TokenUsage {
585            input_tokens: u.prompt_tokens,
586            output_tokens: u.completion_tokens,
587            cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
588        });
589        let message = native_response
590            .choices
591            .into_iter()
592            .next()
593            .map(|c| c.message)
594            .ok_or_else(|| {
595                ::zeroclaw_log::record!(
596                    ERROR,
597                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
598                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
599                    "openai: empty choices in response"
600                );
601                anyhow::Error::msg("No response from OpenAI")
602            })?;
603        let mut result = Self::parse_native_response(message);
604        result.usage = usage;
605        Ok(result)
606    }
607
608    async fn warmup(&self) -> anyhow::Result<()> {
609        if let Some(credential) = self.credential.as_ref() {
610            self.http_client()
611                .get(format!("{}/models", self.base_url))
612                .header("Authorization", format!("Bearer {credential}"))
613                .send()
614                .await?
615                .error_for_status()?;
616        }
617        Ok(())
618    }
619
620    async fn list_models(&self) -> anyhow::Result<Vec<String>> {
621        // OpenAI's /v1/models requires a credential. models.dev is the no-auth
622        // path onboard uses before the user has entered a key.
623        crate::models_dev::list_models_for("openai").await
624    }
625}
626
627impl ::zeroclaw_api::attribution::Attributable for OpenAiModelProvider {
628    fn role(&self) -> ::zeroclaw_api::attribution::Role {
629        ::zeroclaw_api::attribution::Role::Provider(
630            ::zeroclaw_api::attribution::ProviderKind::Model(
631                ::zeroclaw_api::attribution::ModelProviderKind::OpenAi,
632            ),
633        )
634    }
635    fn alias(&self) -> &str {
636        &self.alias
637    }
638}
639
640// ── OpenAI Responses API provider (wire_api = "responses") ────────────────
641//
642// Uses the OpenAI Responses API (`/v1/responses`) with a standard API key.
643// Supports full streaming tool calls, unlike the chat-completions `OpenAiModelProvider`.
644// Constructed by the factory when `wire_api = "responses"` without Codex OAuth.
645
646/// Request body for the standard OpenAI Responses API.
647#[derive(Debug, Serialize)]
648struct ResponsesApiRequest {
649    model: String,
650    input: Vec<serde_json::Value>,
651    #[serde(skip_serializing_if = "Option::is_none")]
652    instructions: Option<String>,
653    stream: bool,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    tools: Option<Vec<ResponsesToolSpec>>,
656    #[serde(skip_serializing_if = "Option::is_none")]
657    tool_choice: Option<String>,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    parallel_tool_calls: Option<bool>,
660    #[serde(skip_serializing_if = "Option::is_none")]
661    temperature: Option<f64>,
662    #[serde(skip_serializing_if = "Option::is_none")]
663    max_output_tokens: Option<u32>,
664    #[serde(skip_serializing_if = "Option::is_none")]
665    reasoning: Option<ResponsesApiReasoning>,
666}
667
668#[derive(Debug, Serialize)]
669struct ResponsesApiReasoning {
670    effort: String,
671}
672
673/// Non-streaming response body from `/v1/responses`.
674#[derive(Debug, Deserialize)]
675struct ResponsesApiBody {
676    #[serde(default)]
677    output: Vec<serde_json::Value>,
678    #[serde(default)]
679    output_text: Option<String>,
680}
681
682fn extract_responses_api_text(body: &ResponsesApiBody) -> Option<String> {
683    if let Some(text) = first_nonempty(body.output_text.as_deref()) {
684        return Some(text);
685    }
686    for item in &body.output {
687        if item.get("type").and_then(serde_json::Value::as_str) != Some("message") {
688            continue;
689        }
690        if let Some(parts) = item.get("content").and_then(serde_json::Value::as_array) {
691            for part in parts {
692                if part.get("type").and_then(serde_json::Value::as_str) == Some("output_text")
693                    && let Some(text) =
694                        first_nonempty(part.get("text").and_then(serde_json::Value::as_str))
695                {
696                    return Some(text);
697                }
698            }
699        }
700    }
701    None
702}
703
704fn extract_responses_api_tool_calls(body: &ResponsesApiBody) -> Vec<ProviderToolCall> {
705    body.output
706        .iter()
707        .filter(|item| {
708            item.get("type").and_then(serde_json::Value::as_str) == Some("function_call")
709        })
710        .filter_map(|item| {
711            let name = item
712                .get("name")
713                .and_then(serde_json::Value::as_str)?
714                .to_string();
715            let arguments = item
716                .get("arguments")
717                .and_then(serde_json::Value::as_str)
718                .unwrap_or("{}")
719                .to_string();
720            let id = item
721                .get("call_id")
722                .and_then(serde_json::Value::as_str)
723                .or_else(|| item.get("id").and_then(serde_json::Value::as_str))
724                .map(ToString::to_string)
725                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
726            Some(ProviderToolCall {
727                id,
728                name,
729                arguments,
730                extra_content: None,
731            })
732        })
733        .collect()
734}
735
736/// Drive a Responses API SSE connection to completion, emitting events on `tx`.
737///
738/// `request_builder` must already have URL, auth headers, `Accept: text/event-stream`,
739/// and the JSON body attached. Sends `StreamEvent::Final` on clean stream end.
740pub(crate) async fn run_responses_sse(
741    request_builder: reqwest::RequestBuilder,
742    tx: &tokio::sync::mpsc::Sender<StreamResult<StreamEvent>>,
743    count_tokens: bool,
744) {
745    let http_response = match request_builder.send().await {
746        Ok(r) => r,
747        Err(err) => {
748            let _ = tx
749                .send(Err(StreamError::ModelProvider(err.to_string())))
750                .await;
751            return;
752        }
753    };
754
755    if !http_response.status().is_success() {
756        let status = http_response.status();
757        let body = http_response.text().await.unwrap_or_default();
758        let sanitized = super::sanitize_api_error(&body);
759        let _ = tx
760            .send(Err(StreamError::ModelProvider(format!(
761                "OpenAI API error ({status}): {sanitized}"
762            ))))
763            .await;
764        return;
765    }
766
767    let mut state = ResponsesStreamState::default();
768    let mut byte_stream = http_response.bytes_stream();
769    let mut pending_utf8: Vec<u8> = Vec::new();
770    let mut chunk_buf = String::new();
771
772    loop {
773        match byte_stream.next().await {
774            Some(Ok(bytes)) => {
775                if let Err(err) =
776                    append_utf8_stream_chunk(&mut chunk_buf, &mut pending_utf8, &bytes)
777                {
778                    let _ = tx
779                        .send(Err(StreamError::ModelProvider(err.to_string())))
780                        .await;
781                    return;
782                }
783            }
784            Some(Err(err)) => {
785                let _ = tx
786                    .send(Err(StreamError::ModelProvider(err.to_string())))
787                    .await;
788                return;
789            }
790            None => break,
791        }
792
793        while let Some(idx) = chunk_buf.find("\n\n") {
794            let chunk_str = chunk_buf[..idx].to_string();
795            chunk_buf = chunk_buf[idx + 2..].to_string();
796
797            match process_sse_chunk(&chunk_str, &mut state) {
798                Ok(events) => {
799                    for event in events {
800                        if let StreamEvent::TextDelta(ref chunk) = event {
801                            let event = if count_tokens {
802                                StreamEvent::TextDelta(
803                                    StreamChunk::delta(chunk.delta.clone()).with_token_estimate(),
804                                )
805                            } else {
806                                event
807                            };
808                            if tx.send(Ok(event)).await.is_err() {
809                                return;
810                            }
811                        } else if tx.send(Ok(event)).await.is_err() {
812                            return;
813                        }
814                    }
815                }
816                Err(err) => {
817                    if err.downcast_ref::<ResponsesStreamApiError>().is_some() {
818                        let _ = tx
819                            .send(Err(StreamError::ModelProvider(err.to_string())))
820                            .await;
821                        return;
822                    }
823                }
824            }
825        }
826    }
827
828    if !chunk_buf.trim().is_empty()
829        && let Ok(events) = process_sse_chunk(&chunk_buf, &mut state)
830    {
831        for event in events {
832            let _ = tx.send(Ok(event)).await;
833        }
834    }
835
836    if !state.saw_text_delta
837        && let Some(text) = state.fallback_text.filter(|t| !t.is_empty())
838    {
839        let chunk = if count_tokens {
840            StreamChunk::delta(text).with_token_estimate()
841        } else {
842            StreamChunk::delta(text)
843        };
844        let _ = tx.send(Ok(StreamEvent::TextDelta(chunk))).await;
845    }
846
847    let _ = tx.send(Ok(StreamEvent::Final)).await;
848}
849
850pub struct OpenAiResponsesModelProvider {
851    alias: String,
852    responses_url: String,
853    credential: Option<String>,
854    max_tokens: Option<u32>,
855    reasoning_effort: Option<String>,
856}
857
858impl OpenAiResponsesModelProvider {
859    pub fn new(alias: &str, api_url: Option<&str>, credential: Option<&str>) -> Self {
860        let responses_url = api_url
861            .map(|url| {
862                let trimmed = url.trim_end_matches('/');
863                if trimmed.ends_with("/responses") {
864                    trimmed.to_string()
865                } else {
866                    format!("{trimmed}/responses")
867                }
868            })
869            .unwrap_or_else(|| RESPONSES_URL.to_string());
870        Self {
871            alias: alias.to_string(),
872            responses_url,
873            credential: credential.map(ToString::to_string),
874            max_tokens: None,
875            reasoning_effort: None,
876        }
877    }
878
879    pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
880        self.max_tokens = max_tokens;
881        self
882    }
883
884    pub fn with_reasoning_effort(mut self, effort: Option<String>) -> Self {
885        self.reasoning_effort = effort;
886        self
887    }
888
889    fn build_request(
890        &self,
891        instructions: Option<String>,
892        input: Vec<serde_json::Value>,
893        tools: Option<Vec<ResponsesToolSpec>>,
894        model: &str,
895        temperature: Option<f64>,
896        stream: bool,
897    ) -> ResponsesApiRequest {
898        let has_tools = tools.is_some();
899        let reasoning = self
900            .reasoning_effort
901            .as_deref()
902            .map(|effort| ResponsesApiReasoning {
903                effort: effort.to_string(),
904            });
905        ResponsesApiRequest {
906            model: model.to_string(),
907            input,
908            instructions,
909            stream,
910            tools,
911            tool_choice: has_tools.then(|| "auto".to_string()),
912            parallel_tool_calls: has_tools.then_some(true),
913            temperature,
914            max_output_tokens: self.max_tokens,
915            reasoning,
916        }
917    }
918
919    fn streaming_client(&self) -> Client {
920        Client::builder()
921            .connect_timeout(std::time::Duration::from_secs(10))
922            .build()
923            .unwrap_or_else(|_| Client::new())
924    }
925}
926
927#[async_trait]
928impl ModelProvider for OpenAiResponsesModelProvider {
929    fn capabilities(&self) -> ProviderCapabilities {
930        ProviderCapabilities {
931            native_tool_calling: true,
932            vision: false,
933            prompt_caching: false,
934            extended_thinking: false,
935        }
936    }
937
938    /// Reports the instance's resolved endpoint so callers can verify which
939    /// host a responses provider will actually hit (e.g. a compat family's
940    /// default base vs. OpenAI's).
941    fn default_base_url(&self) -> Option<&str> {
942        Some(&self.responses_url)
943    }
944
945    fn default_wire_api(&self) -> &str {
946        "responses"
947    }
948
949    fn supports_native_tools(&self) -> bool {
950        true
951    }
952
953    fn supports_streaming(&self) -> bool {
954        true
955    }
956
957    fn supports_streaming_tool_events(&self) -> bool {
958        true
959    }
960
961    async fn chat_with_system(
962        &self,
963        system_prompt: Option<&str>,
964        message: &str,
965        model: &str,
966        temperature: Option<f64>,
967    ) -> anyhow::Result<String> {
968        let credential = self.credential.as_ref().ok_or_else(|| {
969            anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
970        })?;
971        let mut messages = Vec::new();
972        if let Some(sys) = system_prompt {
973            messages.push(ChatMessage::system(sys));
974        }
975        messages.push(ChatMessage::user(message));
976        let (instructions, input) = build_responses_input(&messages);
977        let instructions = if instructions.is_empty() {
978            None
979        } else {
980            Some(instructions)
981        };
982        let req = self.build_request(instructions, input, None, model, temperature, false);
983        let response = Client::new()
984            .post(&self.responses_url)
985            .header("Authorization", format!("Bearer {credential}"))
986            .json(&req)
987            .send()
988            .await?;
989        if !response.status().is_success() {
990            return Err(super::api_error("OpenAI", response).await);
991        }
992        let body: ResponsesApiBody = response.json().await?;
993        extract_responses_api_text(&body)
994            .ok_or_else(|| anyhow::Error::msg("No response from OpenAI"))
995    }
996
997    async fn chat(
998        &self,
999        request: ProviderChatRequest<'_>,
1000        model: &str,
1001        temperature: Option<f64>,
1002    ) -> anyhow::Result<ProviderChatResponse> {
1003        let credential = self.credential.as_ref().ok_or_else(|| {
1004            anyhow::Error::msg("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
1005        })?;
1006        let (instructions, input) = build_responses_input(request.messages);
1007        let instructions = if instructions.is_empty() {
1008            None
1009        } else {
1010            Some(instructions)
1011        };
1012        let tools = convert_tools(request.tools);
1013        let req = self.build_request(instructions, input, tools, model, temperature, false);
1014        let response = Client::new()
1015            .post(&self.responses_url)
1016            .header("Authorization", format!("Bearer {credential}"))
1017            .json(&req)
1018            .send()
1019            .await?;
1020        if !response.status().is_success() {
1021            return Err(super::api_error("OpenAI", response).await);
1022        }
1023        let body: ResponsesApiBody = response.json().await?;
1024        Ok(ProviderChatResponse {
1025            text: extract_responses_api_text(&body),
1026            tool_calls: extract_responses_api_tool_calls(&body),
1027            usage: None,
1028            reasoning_content: None,
1029        })
1030    }
1031
1032    fn stream_chat(
1033        &self,
1034        request: ProviderChatRequest<'_>,
1035        model: &str,
1036        temperature: Option<f64>,
1037        options: StreamOptions,
1038    ) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
1039        if !options.enabled {
1040            return stream::once(async { Ok(StreamEvent::Final) }).boxed();
1041        }
1042
1043        let credential = match self.credential.clone() {
1044            Some(c) => c,
1045            None => {
1046                let err = StreamError::ModelProvider("OpenAI API key not set".to_string());
1047                return stream::once(async move { Err(err) }).boxed();
1048            }
1049        };
1050
1051        let messages_owned = request.messages.to_vec();
1052        let tools_owned = request.tools.map(<[ToolSpec]>::to_vec);
1053        let model = model.to_string();
1054        let responses_url = self.responses_url.clone();
1055        let count_tokens = options.count_tokens;
1056        let reasoning_effort = self.reasoning_effort.clone();
1057        let max_tokens = self.max_tokens;
1058        let client = self.streaming_client();
1059
1060        let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamEvent>>(100);
1061        let handle = ::zeroclaw_spawn::spawn!(async move {
1062            let (instructions, input) = build_responses_input(&messages_owned);
1063            let instructions = if instructions.is_empty() {
1064                None
1065            } else {
1066                Some(instructions)
1067            };
1068            let tools = convert_tools(tools_owned.as_deref());
1069            let has_tools = tools.is_some();
1070            let reasoning = reasoning_effort
1071                .as_deref()
1072                .map(|effort| ResponsesApiReasoning {
1073                    effort: effort.to_string(),
1074                });
1075            let req = ResponsesApiRequest {
1076                model,
1077                input,
1078                instructions,
1079                stream: true,
1080                tools,
1081                tool_choice: has_tools.then(|| "auto".to_string()),
1082                parallel_tool_calls: has_tools.then_some(true),
1083                temperature,
1084                max_output_tokens: max_tokens,
1085                reasoning,
1086            };
1087
1088            let request_builder = client
1089                .post(&responses_url)
1090                .header("Authorization", format!("Bearer {credential}"))
1091                .header("Accept", "text/event-stream")
1092                .json(&req);
1093
1094            run_responses_sse(request_builder, &tx, count_tokens).await;
1095        });
1096
1097        let guard = AbortOnDrop::new(handle.abort_handle());
1098        stream::unfold((rx, guard), |(mut rx, guard)| async move {
1099            rx.recv().await.map(|event| (event, (rx, guard)))
1100        })
1101        .boxed()
1102    }
1103}
1104
1105impl ::zeroclaw_api::attribution::Attributable for OpenAiResponsesModelProvider {
1106    fn role(&self) -> ::zeroclaw_api::attribution::Role {
1107        ::zeroclaw_api::attribution::Role::Provider(
1108            ::zeroclaw_api::attribution::ProviderKind::Model(
1109                ::zeroclaw_api::attribution::ModelProviderKind::OpenAi,
1110            ),
1111        )
1112    }
1113    fn alias(&self) -> &str {
1114        &self.alias
1115    }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    #[test]
1123    fn creates_with_key() {
1124        let p = OpenAiModelProvider::new("test", Some("openai-test-credential"));
1125        assert_eq!(p.credential.as_deref(), Some("openai-test-credential"));
1126    }
1127
1128    #[test]
1129    fn creates_without_key() {
1130        let p = OpenAiModelProvider::new("test", None);
1131        assert!(p.credential.is_none());
1132    }
1133
1134    #[test]
1135    fn responses_url_appends_responses_to_custom_base() {
1136        let p =
1137            OpenAiResponsesModelProvider::new("opencode", Some("https://opencode.ai/zen/v1"), None);
1138        assert_eq!(p.responses_url, "https://opencode.ai/zen/v1/responses");
1139    }
1140
1141    #[test]
1142    fn responses_url_defaults_to_openai_when_base_absent() {
1143        let p = OpenAiResponsesModelProvider::new("test", None, None);
1144        assert_eq!(p.responses_url, RESPONSES_URL);
1145    }
1146
1147    #[test]
1148    fn creates_with_empty_key() {
1149        let p = OpenAiModelProvider::new("test", Some(""));
1150        assert_eq!(p.credential.as_deref(), Some(""));
1151    }
1152
1153    #[tokio::test]
1154    async fn chat_fails_without_key() {
1155        let p = OpenAiModelProvider::new("test", None);
1156        let result = p.chat_with_system(None, "hello", "gpt-4o", Some(0.7)).await;
1157        assert!(result.is_err());
1158        assert!(result.unwrap_err().to_string().contains("API key not set"));
1159    }
1160
1161    #[tokio::test]
1162    async fn chat_with_system_fails_without_key() {
1163        let p = OpenAiModelProvider::new("test", None);
1164        let result = p
1165            .chat_with_system(Some("You are ZeroClaw"), "test", "gpt-4o", Some(0.5))
1166            .await;
1167        assert!(result.is_err());
1168    }
1169
1170    #[test]
1171    fn request_serializes_with_system_message() {
1172        let req = ChatRequest {
1173            model: "gpt-4o".to_string(),
1174            messages: vec![
1175                Message {
1176                    role: "system".to_string(),
1177                    content: "You are ZeroClaw".to_string(),
1178                },
1179                Message {
1180                    role: "user".to_string(),
1181                    content: "hello".to_string(),
1182                },
1183            ],
1184            temperature: Some(0.7),
1185            max_tokens: None,
1186        };
1187        let json = serde_json::to_string(&req).unwrap();
1188        assert!(json.contains("\"role\":\"system\""));
1189        assert!(json.contains("\"role\":\"user\""));
1190        assert!(json.contains("gpt-4o"));
1191    }
1192
1193    #[test]
1194    fn request_serializes_without_system() {
1195        let req = ChatRequest {
1196            model: "gpt-4o".to_string(),
1197            messages: vec![Message {
1198                role: "user".to_string(),
1199                content: "hello".to_string(),
1200            }],
1201            temperature: Some(0.0),
1202            max_tokens: None,
1203        };
1204        let json = serde_json::to_string(&req).unwrap();
1205        assert!(!json.contains("system"));
1206        assert!(json.contains("\"temperature\":0.0"));
1207    }
1208
1209    #[test]
1210    fn response_deserializes_single_choice() {
1211        let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#;
1212        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1213        assert_eq!(resp.choices.len(), 1);
1214        assert_eq!(resp.choices[0].message.effective_content(), "Hi!");
1215    }
1216
1217    #[test]
1218    fn response_deserializes_empty_choices() {
1219        let json = r#"{"choices":[]}"#;
1220        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1221        assert!(resp.choices.is_empty());
1222    }
1223
1224    #[test]
1225    fn response_deserializes_multiple_choices() {
1226        let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#;
1227        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1228        assert_eq!(resp.choices.len(), 2);
1229        assert_eq!(resp.choices[0].message.effective_content(), "A");
1230    }
1231
1232    #[test]
1233    fn response_with_unicode() {
1234        let json = r#"{"choices":[{"message":{"content":"Hello \u03A9"}}]}"#;
1235        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1236        assert_eq!(
1237            resp.choices[0].message.effective_content(),
1238            "Hello \u{03A9}"
1239        );
1240    }
1241
1242    #[test]
1243    fn response_with_long_content() {
1244        let long = "x".repeat(100_000);
1245        let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#);
1246        let resp: ChatResponse = serde_json::from_str(&json).unwrap();
1247        assert_eq!(
1248            resp.choices[0].message.content.as_ref().unwrap().len(),
1249            100_000
1250        );
1251    }
1252
1253    #[tokio::test]
1254    async fn warmup_without_key_is_noop() {
1255        let model_provider = OpenAiModelProvider::new("test", None);
1256        let result = model_provider.warmup().await;
1257        assert!(result.is_ok());
1258    }
1259
1260    // ----------------------------------------------------------
1261    // Reasoning model fallback tests (reasoning_content)
1262    // ----------------------------------------------------------
1263
1264    #[test]
1265    fn reasoning_content_fallback_empty_content() {
1266        let json = r#"{"choices":[{"message":{"content":"","reasoning_content":"Thinking..."}}]}"#;
1267        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1268        assert_eq!(resp.choices[0].message.effective_content(), "Thinking...");
1269    }
1270
1271    #[test]
1272    fn reasoning_content_fallback_null_content() {
1273        let json =
1274            r#"{"choices":[{"message":{"content":null,"reasoning_content":"Thinking..."}}]}"#;
1275        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1276        assert_eq!(resp.choices[0].message.effective_content(), "Thinking...");
1277    }
1278
1279    #[test]
1280    fn reasoning_content_not_used_when_content_present() {
1281        let json = r#"{"choices":[{"message":{"content":"Hello","reasoning_content":"Ignored"}}]}"#;
1282        let resp: ChatResponse = serde_json::from_str(json).unwrap();
1283        assert_eq!(resp.choices[0].message.effective_content(), "Hello");
1284    }
1285
1286    #[test]
1287    fn native_response_reasoning_content_fallback() {
1288        let json =
1289            r#"{"choices":[{"message":{"content":"","reasoning_content":"Native thinking"}}]}"#;
1290        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1291        let msg = &resp.choices[0].message;
1292        assert_eq!(msg.effective_content(), Some("Native thinking".to_string()));
1293    }
1294
1295    #[test]
1296    fn native_response_reasoning_content_ignored_when_content_present() {
1297        let json =
1298            r#"{"choices":[{"message":{"content":"Real answer","reasoning_content":"Ignored"}}]}"#;
1299        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1300        let msg = &resp.choices[0].message;
1301        assert_eq!(msg.effective_content(), Some("Real answer".to_string()));
1302    }
1303
1304    #[tokio::test]
1305    async fn chat_with_tools_fails_without_key() {
1306        let p = OpenAiModelProvider::new("test", None);
1307        let messages = vec![ChatMessage::user("hello".to_string())];
1308        let tools = vec![serde_json::json!({
1309            "type": "function",
1310            "function": {
1311                "name": "shell",
1312                "description": "Run a shell command",
1313                "parameters": {
1314                    "type": "object",
1315                    "properties": {
1316                        "command": { "type": "string" }
1317                    },
1318                    "required": ["command"]
1319                }
1320            }
1321        })];
1322        let result = p
1323            .chat_with_tools(&messages, &tools, "gpt-4o", Some(0.7))
1324            .await;
1325        assert!(result.is_err());
1326        assert!(result.unwrap_err().to_string().contains("API key not set"));
1327    }
1328
1329    #[tokio::test]
1330    async fn chat_with_tools_rejects_invalid_tool_shape() {
1331        let p = OpenAiModelProvider::new("test", Some("openai-test-credential"));
1332        let messages = vec![ChatMessage::user("hello".to_string())];
1333        let tools = vec![serde_json::json!({
1334            "type": "function",
1335            "function": {
1336                "name": "shell",
1337                "parameters": {
1338                    "type": "object",
1339                    "properties": {
1340                        "command": { "type": "string" }
1341                    },
1342                    "required": ["command"]
1343                }
1344            }
1345        })];
1346
1347        let result = p
1348            .chat_with_tools(&messages, &tools, "gpt-4o", Some(0.7))
1349            .await;
1350        assert!(result.is_err());
1351        assert!(
1352            result
1353                .unwrap_err()
1354                .to_string()
1355                .contains("Invalid OpenAI tool specification")
1356        );
1357    }
1358
1359    #[test]
1360    fn native_tool_spec_deserializes_from_openai_format() {
1361        let json = serde_json::json!({
1362            "type": "function",
1363            "function": {
1364                "name": "shell",
1365                "description": "Run a shell command",
1366                "parameters": {
1367                    "type": "object",
1368                    "properties": {
1369                        "command": { "type": "string" }
1370                    },
1371                    "required": ["command"]
1372                }
1373            }
1374        });
1375        let spec = parse_native_tool_spec(json).unwrap();
1376        assert_eq!(spec.kind, "function");
1377        assert_eq!(spec.function.name, "shell");
1378    }
1379
1380    #[test]
1381    fn native_response_parses_usage() {
1382        let json = r#"{
1383            "choices": [{"message": {"content": "Hello"}}],
1384            "usage": {"prompt_tokens": 100, "completion_tokens": 50}
1385        }"#;
1386        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1387        let usage = resp.usage.unwrap();
1388        assert_eq!(usage.prompt_tokens, Some(100));
1389        assert_eq!(usage.completion_tokens, Some(50));
1390    }
1391
1392    #[test]
1393    fn native_response_parses_without_usage() {
1394        let json = r#"{"choices": [{"message": {"content": "Hello"}}]}"#;
1395        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1396        assert!(resp.usage.is_none());
1397    }
1398
1399    // ═══════════════════════════════════════════════════════════════════════
1400    // reasoning_content pass-through tests
1401    // ═══════════════════════════════════════════════════════════════════════
1402
1403    #[test]
1404    fn parse_native_response_captures_reasoning_content() {
1405        let json = r#"{"choices":[{"message":{
1406            "content":"answer",
1407            "reasoning_content":"thinking step",
1408            "tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{}"}}]
1409        }}]}"#;
1410        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1411        let message = resp.choices.into_iter().next().unwrap().message;
1412        let parsed = OpenAiModelProvider::parse_native_response(message);
1413        assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
1414        assert_eq!(parsed.tool_calls.len(), 1);
1415    }
1416
1417    #[test]
1418    fn parse_native_response_none_reasoning_content_for_normal_model() {
1419        let json = r#"{"choices":[{"message":{"content":"hello"}}]}"#;
1420        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1421        let message = resp.choices.into_iter().next().unwrap().message;
1422        let parsed = OpenAiModelProvider::parse_native_response(message);
1423        assert!(parsed.reasoning_content.is_none());
1424    }
1425
1426    #[test]
1427    fn convert_messages_round_trips_reasoning_content() {
1428        use zeroclaw_api::model_provider::ChatMessage;
1429
1430        let history_json = serde_json::json!({
1431            "content": "I will check",
1432            "tool_calls": [{
1433                "id": "tc_1",
1434                "name": "shell",
1435                "arguments": "{}"
1436            }],
1437            "reasoning_content": "Let me think..."
1438        });
1439
1440        let messages = vec![ChatMessage::assistant(history_json.to_string())];
1441        let native = OpenAiModelProvider::convert_messages(&messages);
1442        assert_eq!(native.len(), 1);
1443        assert_eq!(
1444            native[0].reasoning_content.as_deref(),
1445            Some("Let me think...")
1446        );
1447    }
1448
1449    #[test]
1450    fn convert_messages_no_reasoning_content_when_absent() {
1451        use zeroclaw_api::model_provider::ChatMessage;
1452
1453        let history_json = serde_json::json!({
1454            "content": "I will check",
1455            "tool_calls": [{
1456                "id": "tc_1",
1457                "name": "shell",
1458                "arguments": "{}"
1459            }]
1460        });
1461
1462        let messages = vec![ChatMessage::assistant(history_json.to_string())];
1463        let native = OpenAiModelProvider::convert_messages(&messages);
1464        assert_eq!(native.len(), 1);
1465        assert!(native[0].reasoning_content.is_none());
1466    }
1467
1468    #[test]
1469    fn native_message_omits_reasoning_content_when_none() {
1470        let msg = NativeMessage {
1471            role: "assistant".to_string(),
1472            content: Some("hi".to_string()),
1473            tool_call_id: None,
1474            tool_calls: None,
1475            reasoning_content: None,
1476        };
1477        let json = serde_json::to_string(&msg).unwrap();
1478        assert!(!json.contains("reasoning_content"));
1479    }
1480
1481    #[test]
1482    fn native_message_includes_reasoning_content_when_some() {
1483        let msg = NativeMessage {
1484            role: "assistant".to_string(),
1485            content: Some("hi".to_string()),
1486            tool_call_id: None,
1487            tool_calls: None,
1488            reasoning_content: Some("thinking...".to_string()),
1489        };
1490        let json = serde_json::to_string(&msg).unwrap();
1491        assert!(json.contains("reasoning_content"));
1492        assert!(json.contains("thinking..."));
1493    }
1494
1495    // ═══════════════════════════════════════════════════════════════════════
1496    // Temperature adjustment tests
1497    // ═══════════════════════════════════════════════════════════════════════
1498
1499    #[test]
1500    fn adjust_temperature_for_o1_models() {
1501        assert_eq!(
1502            OpenAiModelProvider::adjust_temperature_for_model("o1", 0.7),
1503            1.0
1504        );
1505        assert_eq!(
1506            OpenAiModelProvider::adjust_temperature_for_model("o1-2024-12-17", 0.5),
1507            1.0
1508        );
1509        assert_eq!(
1510            OpenAiModelProvider::adjust_temperature_for_model("o1-mini", 0.5),
1511            1.0
1512        );
1513        assert_eq!(
1514            OpenAiModelProvider::adjust_temperature_for_model("o1-mini-2024-09-12", 0.7),
1515            1.0
1516        );
1517    }
1518
1519    #[test]
1520    fn adjust_temperature_for_o3_models() {
1521        assert_eq!(
1522            OpenAiModelProvider::adjust_temperature_for_model("o3", 0.7),
1523            1.0
1524        );
1525        assert_eq!(
1526            OpenAiModelProvider::adjust_temperature_for_model("o3-2025-04-16", 0.5),
1527            1.0
1528        );
1529        assert_eq!(
1530            OpenAiModelProvider::adjust_temperature_for_model("o3-mini", 0.3),
1531            1.0
1532        );
1533        assert_eq!(
1534            OpenAiModelProvider::adjust_temperature_for_model("o3-mini-2025-01-31", 0.8),
1535            1.0
1536        );
1537    }
1538
1539    #[test]
1540    fn adjust_temperature_for_o4_models() {
1541        assert_eq!(
1542            OpenAiModelProvider::adjust_temperature_for_model("o4-mini", 0.7),
1543            1.0
1544        );
1545        assert_eq!(
1546            OpenAiModelProvider::adjust_temperature_for_model("o4-mini-2025-04-16", 0.5),
1547            1.0
1548        );
1549    }
1550
1551    #[test]
1552    fn adjust_temperature_for_gpt5_models() {
1553        assert_eq!(
1554            OpenAiModelProvider::adjust_temperature_for_model("gpt-5", 0.7),
1555            1.0
1556        );
1557        assert_eq!(
1558            OpenAiModelProvider::adjust_temperature_for_model("gpt-5-2025-08-07", 0.5),
1559            1.0
1560        );
1561        assert_eq!(
1562            OpenAiModelProvider::adjust_temperature_for_model("gpt-5-mini", 0.3),
1563            1.0
1564        );
1565        assert_eq!(
1566            OpenAiModelProvider::adjust_temperature_for_model("gpt-5-mini-2025-08-07", 0.8),
1567            1.0
1568        );
1569        assert_eq!(
1570            OpenAiModelProvider::adjust_temperature_for_model("gpt-5-nano", 0.6),
1571            1.0
1572        );
1573        assert_eq!(
1574            OpenAiModelProvider::adjust_temperature_for_model("gpt-5-nano-2025-08-07", 0.4),
1575            1.0
1576        );
1577    }
1578
1579    #[test]
1580    fn adjust_temperature_for_gpt5_chat_latest_models() {
1581        assert_eq!(
1582            OpenAiModelProvider::adjust_temperature_for_model("gpt-5.1-chat-latest", 0.7),
1583            1.0
1584        );
1585        assert_eq!(
1586            OpenAiModelProvider::adjust_temperature_for_model("gpt-5.2-chat-latest", 0.5),
1587            1.0
1588        );
1589        assert_eq!(
1590            OpenAiModelProvider::adjust_temperature_for_model("gpt-5.3-chat-latest", 0.3),
1591            1.0
1592        );
1593    }
1594
1595    #[test]
1596    fn adjust_temperature_preserves_for_standard_models() {
1597        assert_eq!(
1598            OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 0.7),
1599            0.7
1600        );
1601        assert_eq!(
1602            OpenAiModelProvider::adjust_temperature_for_model("gpt-4-turbo", 0.5),
1603            0.5
1604        );
1605        assert_eq!(
1606            OpenAiModelProvider::adjust_temperature_for_model("gpt-3.5-turbo", 0.3),
1607            0.3
1608        );
1609        assert_eq!(
1610            OpenAiModelProvider::adjust_temperature_for_model("gpt-4", 1.0),
1611            1.0
1612        );
1613    }
1614
1615    #[test]
1616    fn adjust_temperature_handles_edge_cases() {
1617        // Temperature 0.0 should be preserved for standard models
1618        assert_eq!(
1619            OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 0.0),
1620            0.0
1621        );
1622        // Temperature 1.0 should be preserved for all models
1623        assert_eq!(
1624            OpenAiModelProvider::adjust_temperature_for_model("o1", 1.0),
1625            1.0
1626        );
1627        assert_eq!(
1628            OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 1.0),
1629            1.0
1630        );
1631    }
1632}