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
18pub(crate) const BASE_URL: &str = "https://api.openai.com/v1";
20
21const RESPONSES_URL: &str = "https://api.openai.com/v1/responses";
23
24pub struct OpenAiModelProvider {
25 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 #[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 #[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 #[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 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 pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
224 self.max_tokens = max_tokens;
225 self
226 }
227
228 fn adjust_temperature_for_model(model: &str, requested_temperature: f64) -> f64 {
231 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 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 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#[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#[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
736pub(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 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 #[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 #[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 #[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 assert_eq!(
1619 OpenAiModelProvider::adjust_temperature_for_model("gpt-4o", 0.0),
1620 0.0
1621 );
1622 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}