1use super::{
2 Env, LlmError, LlmProvider, LlmRequest, LlmResponse, MessageRole, Reasoning, ReasoningSupport,
3 TokenUsage, classify_api_error, describe_transport_error, refuses_reasoning,
4};
5use async_trait::async_trait;
6use eventsource_stream::Eventsource;
7use futures::{StreamExt, stream::BoxStream};
8use serde::{Deserialize, Serialize};
9use tracing::{debug, error, info, warn};
10
11#[derive(Debug, Clone)]
13pub struct OpenAiConfig {
14 pub base_url: String,
15 pub model: String,
16 pub api_key: Option<String>,
17 pub extra_headers: Vec<(String, String)>,
21 pub reasoning_dialect: ReasoningDialect,
25 pub reasoning: Option<Reasoning>,
28 pub stream_usage: bool,
36}
37
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum ReasoningDialect {
45 OpenRouter,
49 #[default]
53 OpenAi,
54}
55
56pub fn reasoning_effort(reasoning: Reasoning) -> Option<&'static str> {
68 match reasoning {
69 Reasoning::Off => Some("none"),
70 Reasoning::Effort(effort) => Some(effort.as_str()),
71 Reasoning::Budget(_) => None,
72 }
73}
74
75pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
77
78pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
82
83pub const OPENROUTER_DEFAULT_MODEL: &str = "z-ai/glm-4.6";
86
87impl OpenAiConfig {
88 pub fn from_env() -> Result<Self, String> {
89 Ok(Self::from_lookup(Env::os()))
90 }
91
92 pub(crate) fn from_lookup(env: Env<'_>) -> Self {
97 Self {
98 base_url: env
99 .get("OPENAI_API_BASE_URL")
100 .or_else(|| env.get("AI_API_BASE_URL"))
101 .unwrap_or_else(|| "http://localhost:11434/v1".to_string()),
102 model: env
103 .get("OPENAI_MODEL")
104 .or_else(|| env.get("AI_MODEL"))
105 .unwrap_or_else(|| "ministral".to_string()),
106 api_key: env.get("OPENAI_API_KEY").or_else(|| env.get("AI_API_KEY")),
107 extra_headers: Vec::new(),
108 reasoning_dialect: ReasoningDialect::OpenAi,
109 reasoning: None,
110 stream_usage: false,
113 }
114 }
115
116 pub fn openrouter(
122 api_key: String,
123 model: String,
124 base_url: Option<String>,
125 http_referer: Option<String>,
126 x_title: Option<String>,
127 ) -> Self {
128 let mut extra_headers = Vec::new();
129 if let Some(referer) = http_referer {
130 extra_headers.push(("HTTP-Referer".to_string(), referer));
131 }
132 if let Some(title) = x_title {
133 extra_headers.push(("X-Title".to_string(), title));
134 }
135 Self {
136 base_url: base_url.unwrap_or_else(|| OPENROUTER_BASE_URL.to_string()),
137 model,
138 api_key: Some(api_key),
139 extra_headers,
140 reasoning_dialect: ReasoningDialect::OpenRouter,
141 reasoning: None,
142 stream_usage: true,
143 }
144 }
145
146 pub fn openrouter_from_env() -> Result<Self, String> {
156 Self::openrouter_from_lookup(Env::os())
157 }
158
159 pub(crate) fn openrouter_from_lookup(env: Env<'_>) -> Result<Self, String> {
161 let api_key = env
162 .get("OPENROUTER_API_KEY")
163 .ok_or_else(|| "OPENROUTER_API_KEY environment variable is required".to_string())?;
164
165 let model = env
166 .get("OPENROUTER_MODEL")
167 .unwrap_or_else(|| OPENROUTER_DEFAULT_MODEL.to_string());
168 let reasoning = env
169 .get("OPENROUTER_REASONING")
170 .map(|value| {
171 value
172 .parse::<Reasoning>()
173 .map_err(|e| format!("OPENROUTER_REASONING: {e}"))
174 })
175 .transpose()?;
176
177 Ok(Self {
178 reasoning,
179 ..Self::openrouter(
180 api_key,
181 model,
182 env.get("OPENROUTER_API_BASE_URL"),
183 env.get("OPENROUTER_HTTP_REFERER"),
184 env.get("OPENROUTER_X_TITLE"),
185 )
186 })
187 }
188}
189
190#[derive(Debug, Serialize)]
191struct ResponseFormat {
192 #[serde(rename = "type")]
193 format_type: String,
194}
195
196#[derive(Debug, Serialize)]
197struct OpenAiChatRequest {
198 model: String,
199 messages: Vec<OpenAiChatMessage>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 temperature: Option<f32>,
202 #[serde(skip_serializing_if = "Option::is_none")]
203 max_tokens: Option<u32>,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 response_format: Option<ResponseFormat>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 tools: Option<Vec<OpenAiTool>>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 stream: Option<bool>,
210 #[serde(skip_serializing_if = "Option::is_none")]
213 stream_options: Option<StreamOptions>,
214 #[serde(skip_serializing_if = "Option::is_none")]
217 reasoning: Option<OpenRouterReasoning>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 reasoning_effort: Option<&'static str>,
221}
222
223impl OpenAiChatRequest {
224 fn with_reasoning(mut self, param: Option<ReasoningParam>) -> Self {
226 match param {
227 Some(ReasoningParam::Unified(reasoning)) => self.reasoning = Some(reasoning),
228 Some(ReasoningParam::Effort(effort)) => self.reasoning_effort = Some(effort),
229 None => {}
230 }
231 self
232 }
233
234 fn asks_for_reasoning(&self) -> bool {
236 self.reasoning.is_some() || self.reasoning_effort.is_some()
237 }
238
239 fn clear_reasoning(&mut self) {
241 self.reasoning = None;
242 self.reasoning_effort = None;
243 }
244}
245
246enum ReasoningParam {
248 Unified(OpenRouterReasoning),
250 Effort(&'static str),
252}
253
254#[derive(Debug, Serialize)]
255struct StreamOptions {
256 include_usage: bool,
257}
258
259#[derive(Debug, Deserialize)]
262struct OpenAiUsage {
263 prompt_tokens: Option<u32>,
264 completion_tokens: Option<u32>,
265 total_tokens: Option<u32>,
266 completion_tokens_details: Option<OpenAiCompletionDetails>,
268}
269
270#[derive(Debug, Deserialize)]
271struct OpenAiCompletionDetails {
272 reasoning_tokens: Option<u32>,
273}
274
275impl From<OpenAiUsage> for TokenUsage {
276 fn from(usage: OpenAiUsage) -> Self {
277 Self {
278 prompt_tokens: usage.prompt_tokens,
279 completion_tokens: usage.completion_tokens,
280 reasoning_tokens: usage
281 .completion_tokens_details
282 .and_then(|details| details.reasoning_tokens),
283 total_tokens: usage.total_tokens,
284 }
285 }
286}
287
288#[derive(Debug, Serialize)]
291struct OpenRouterReasoning {
292 #[serde(skip_serializing_if = "Option::is_none")]
293 effort: Option<&'static str>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 max_tokens: Option<u32>,
296 enabled: bool,
297}
298
299impl From<Reasoning> for OpenRouterReasoning {
300 fn from(reasoning: Reasoning) -> Self {
301 let (effort, max_tokens, enabled) = match reasoning {
302 Reasoning::Off => (None, None, false),
303 Reasoning::Effort(effort) => (Some(effort.as_str()), None, true),
304 Reasoning::Budget(max_tokens) => (None, Some(max_tokens), true),
305 };
306 Self {
307 effort,
308 max_tokens,
309 enabled,
310 }
311 }
312}
313
314#[derive(Debug, Serialize)]
315struct OpenAiTool {
316 #[serde(rename = "type")]
317 tool_type: String,
318 function: OpenAiFunction,
319}
320
321#[derive(Debug, Serialize)]
322struct OpenAiFunction {
323 name: String,
324 description: String,
325 parameters: serde_json::Value,
326}
327
328#[derive(Debug, Serialize, Deserialize)]
329struct OpenAiChatMessage {
330 role: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 content: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 tool_calls: Option<Vec<OpenAiToolCall>>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 tool_call_id: Option<String>,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 name: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 reasoning: Option<String>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
345 reasoning_content: Option<String>,
346}
347
348#[derive(Debug, Serialize, Deserialize)]
349struct OpenAiToolCall {
350 id: String,
351 #[serde(rename = "type")]
352 tool_type: String,
353 function: OpenAiFunctionCall,
354}
355
356#[derive(Debug, Serialize, Deserialize)]
357struct OpenAiFunctionCall {
358 name: String,
359 arguments: String,
360}
361
362#[derive(Debug, Deserialize)]
363struct OpenAiChatResponse {
364 choices: Vec<ChatChoice>,
365 usage: Option<OpenAiUsage>,
366}
367
368#[derive(Debug, Deserialize)]
369struct ChatChoice {
370 message: OpenAiChatMessage,
371}
372
373#[derive(Debug, Deserialize)]
374struct OpenAiStreamChunk {
375 #[serde(default)]
378 choices: Vec<StreamChoice>,
379 usage: Option<OpenAiUsage>,
380}
381
382#[derive(Debug, Deserialize)]
383struct StreamChoice {
384 delta: StreamDelta,
385}
386
387#[derive(Debug, Deserialize)]
388struct StreamDelta {
389 content: Option<String>,
390 #[serde(default)]
391 reasoning: Option<String>,
392 #[serde(default)]
393 reasoning_content: Option<String>,
394 tool_calls: Option<Vec<StreamToolCall>>,
395}
396
397#[derive(Debug, Deserialize)]
398struct StreamToolCall {
399 index: u32,
400 id: Option<String>,
401 function: Option<StreamFunctionCall>,
402}
403
404#[derive(Debug, Deserialize)]
405struct StreamFunctionCall {
406 name: Option<String>,
407 arguments: Option<String>,
408}
409
410#[derive(Clone)]
411pub struct OpenAiProvider {
412 config: OpenAiConfig,
413 client: reqwest::Client,
414 reasoning_support: ReasoningSupport,
417}
418
419const REASONING_FIELD: &str = "reasoning";
422
423impl OpenAiProvider {
424 pub fn new(config: OpenAiConfig) -> Self {
425 Self {
426 config,
427 client: reqwest::Client::new(),
428 reasoning_support: ReasoningSupport::default(),
429 }
430 }
431
432 pub fn from_env() -> Result<Self, String> {
433 let config = OpenAiConfig::from_env()?;
434 Ok(Self::new(config))
435 }
436
437 fn reasoning_for(&self, request: &LlmRequest) -> Option<ReasoningParam> {
444 let reasoning = request.reasoning.or(self.config.reasoning)?;
445 if self.reasoning_support.refused() {
446 return None;
447 }
448 match self.config.reasoning_dialect {
449 ReasoningDialect::OpenRouter => Some(ReasoningParam::Unified(reasoning.into())),
450 ReasoningDialect::OpenAi => match reasoning_effort(reasoning) {
451 Some(effort) => Some(ReasoningParam::Effort(effort)),
452 None => {
453 debug!(
454 base_url = %self.config.base_url,
455 %reasoning,
456 "`reasoning_effort` has no field for a token budget; sending the request without it"
457 );
458 None
459 }
460 },
461 }
462 }
463
464 async fn post(
466 &self,
467 url: &str,
468 body: &OpenAiChatRequest,
469 ) -> Result<reqwest::Response, LlmError> {
470 let mut req_builder = self.client.post(url).json(body);
471
472 if let Some(ref api_key) = self.config.api_key {
473 req_builder = req_builder.bearer_auth(api_key);
474 }
475
476 for (name, value) in &self.config.extra_headers {
477 req_builder = req_builder.header(name.as_str(), value.as_str());
478 }
479
480 req_builder.send().await.map_err(|e| {
481 error!(url = %url, error = %e, "Failed to send request to OpenAI API");
482 LlmError::NetworkError(describe_transport_error(&e))
483 })
484 }
485
486 async fn api_error(&self, label: &str, response: reqwest::Response) -> LlmError {
488 let status = response.status();
489 let error_text = response
490 .text()
491 .await
492 .unwrap_or_else(|_| "Unknown error".to_string());
493 error!(status = %status, error = %error_text, "OpenAI API returned error");
494 classify_api_error(label, status, &error_text)
495 }
496
497 async fn send_chat_request(
506 &self,
507 url: &str,
508 mut body: OpenAiChatRequest,
509 label: &str,
510 ) -> Result<reqwest::Response, LlmError> {
511 let response = self.post(url, &body).await?;
512 if response.status().is_success() {
513 return Ok(response);
514 }
515
516 let status = response.status();
519 let error_text = response
520 .text()
521 .await
522 .unwrap_or_else(|_| "Unknown error".to_string());
523
524 if !(body.asks_for_reasoning()
525 && refuses_reasoning(status.as_u16(), &error_text, REASONING_FIELD))
526 {
527 error!(status = %status, error = %error_text, "OpenAI API returned error");
528 return Err(classify_api_error(label, status, &error_text));
529 }
530
531 warn!(
532 model = %self.config.model,
533 %status,
534 error = %error_text,
535 "model refused the reasoning parameter; retrying without it"
536 );
537 body.clear_reasoning();
538
539 let retried = self.post(url, &body).await?;
540 if retried.status().is_success() {
541 self.reasoning_support.record_refusal();
547 Ok(retried)
548 } else {
549 Err(self.api_error(label, retried).await)
550 }
551 }
552}
553
554#[async_trait]
555impl LlmProvider for OpenAiProvider {
556 async fn chat_completion(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
557 let url = format!("{}/chat/completions", self.config.base_url);
558 let reasoning = self.reasoning_for(&request);
559
560 let response_format = if request.force_json {
561 Some(ResponseFormat {
562 format_type: "json_object".to_string(),
563 })
564 } else {
565 None
566 };
567
568 let messages = request
569 .messages
570 .into_iter()
571 .map(|msg| OpenAiChatMessage {
572 role: match msg.role {
573 MessageRole::System => "system".to_string(),
574 MessageRole::User => "user".to_string(),
575 MessageRole::Assistant => "assistant".to_string(),
576 MessageRole::Tool => "tool".to_string(),
577 },
578 content: msg.content,
579 tool_calls: msg.tool_calls.map(|calls| {
580 calls
581 .into_iter()
582 .map(|c| OpenAiToolCall {
583 id: c.id,
584 tool_type: "function".to_string(),
585 function: OpenAiFunctionCall {
586 name: c.name,
587 arguments: c.arguments,
588 },
589 })
590 .collect()
591 }),
592 tool_call_id: msg.tool_call_id,
593 name: msg.name,
594 reasoning: None,
595 reasoning_content: None,
596 })
597 .collect();
598
599 let tools = request.tools.map(|tools| {
600 tools
601 .into_iter()
602 .map(|t| OpenAiTool {
603 tool_type: "function".to_string(),
604 function: OpenAiFunction {
605 name: t.name,
606 description: t.description,
607 parameters: t.parameters,
608 },
609 })
610 .collect()
611 });
612
613 let api_request = OpenAiChatRequest {
614 model: self.config.model.clone(),
615 messages,
616 temperature: request.temperature,
617 max_tokens: request.max_tokens,
618 response_format,
619 tools,
620 stream: None,
621 stream_options: None,
622 reasoning: None,
623 reasoning_effort: None,
624 }
625 .with_reasoning(reasoning);
626
627 debug!(
628 model = %self.config.model,
629 url = %url,
630 message_count = api_request.messages.len(),
631 "Sending chat completion request"
632 );
633
634 let response = self
635 .send_chat_request(&url, api_request, "OpenAI API error")
636 .await?;
637
638 let completion: OpenAiChatResponse = response.json().await.map_err(|e| {
639 error!(error = %e, "Failed to parse OpenAI API response");
640 LlmError::SerializationError(e.to_string())
641 })?;
642
643 let choice = completion.choices.into_iter().next().ok_or_else(|| {
644 warn!("No choices in OpenAI API response");
645 LlmError::ProviderError("No response from AI".to_string())
646 })?;
647
648 let tool_calls = choice.message.tool_calls.map(|calls| {
649 calls
650 .into_iter()
651 .map(|c| super::ToolCall {
652 id: c.id,
653 name: c.function.name,
654 arguments: c.function.arguments,
655 })
656 .collect()
657 });
658
659 let message_content = choice.message.content;
660 let reasoning = choice
661 .message
662 .reasoning
663 .or(choice.message.reasoning_content);
664
665 info!(
666 has_content = message_content.is_some(),
667 has_tools = tool_calls.is_some(),
668 has_reasoning = reasoning.is_some(),
669 "Received chat completion response"
670 );
671
672 Ok(LlmResponse {
673 content: message_content,
674 tool_calls,
675 reasoning,
676 usage: completion.usage.map(TokenUsage::from),
677 })
678 }
679
680 async fn chat_completion_stream(
681 &self,
682 request: LlmRequest,
683 ) -> Result<BoxStream<'static, Result<super::LlmStreamEvent, LlmError>>, LlmError> {
684 let url = format!("{}/chat/completions", self.config.base_url);
685 let reasoning = self.reasoning_for(&request);
686
687 let response_format = if request.force_json {
688 Some(ResponseFormat {
689 format_type: "json_object".to_string(),
690 })
691 } else {
692 None
693 };
694
695 let messages: Vec<OpenAiChatMessage> = request
696 .messages
697 .into_iter()
698 .map(|msg| OpenAiChatMessage {
699 role: match msg.role {
700 MessageRole::System => "system".to_string(),
701 MessageRole::User => "user".to_string(),
702 MessageRole::Assistant => "assistant".to_string(),
703 MessageRole::Tool => "tool".to_string(),
704 },
705 content: msg.content,
706 tool_calls: msg.tool_calls.map(|calls| {
707 calls
708 .into_iter()
709 .map(|c| OpenAiToolCall {
710 id: c.id,
711 tool_type: "function".to_string(),
712 function: OpenAiFunctionCall {
713 name: c.name,
714 arguments: c.arguments,
715 },
716 })
717 .collect()
718 }),
719 tool_call_id: msg.tool_call_id,
720 name: msg.name,
721 reasoning: None,
722 reasoning_content: None,
723 })
724 .collect();
725
726 let tools = request.tools.map(|tools| {
727 tools
728 .into_iter()
729 .map(|t| OpenAiTool {
730 tool_type: "function".to_string(),
731 function: OpenAiFunction {
732 name: t.name,
733 description: t.description,
734 parameters: t.parameters,
735 },
736 })
737 .collect()
738 });
739
740 let api_request = OpenAiChatRequest {
741 model: self.config.model.clone(),
742 messages,
743 temperature: request.temperature,
744 max_tokens: request.max_tokens,
745 response_format,
746 tools,
747 stream: Some(true),
748 stream_options: self.config.stream_usage.then_some(StreamOptions {
749 include_usage: true,
750 }),
751 reasoning: None,
752 reasoning_effort: None,
753 }
754 .with_reasoning(reasoning);
755
756 debug!(
757 model = %self.config.model,
758 url = %url,
759 "Sending streaming chat completion request"
760 );
761
762 let response = self
763 .send_chat_request(&url, api_request, "OpenAI stream error")
764 .await?;
765
766 let mut event_stream = response.bytes_stream().eventsource();
767
768 let stream = async_stream::try_stream! {
769 let mut pending_tools: std::collections::HashMap<u32, super::ToolCall> = std::collections::HashMap::new();
771
772 while let Some(event_res) = event_stream.next().await {
773 let event = match event_res {
774 Ok(e) => e,
775 Err(e) => {
776 yield Err(LlmError::NetworkError(format!("SSE error: {}", describe_transport_error(&e))))?;
777 continue;
778 }
779 };
780
781 let data = event.data;
782 if data == "[DONE]" {
783 let mut indices: Vec<u32> = pending_tools.keys().copied().collect();
785 indices.sort_unstable();
786 for idx in indices {
787 if let Some(tool_call) = pending_tools.remove(&idx) {
788 yield super::LlmStreamEvent::ToolCall(tool_call);
789 }
790 }
791 break;
792 }
793
794 let chunk: OpenAiStreamChunk = match serde_json::from_str(&data) {
795 Ok(c) => c,
796 Err(_e) => {
797 debug!("Skipping unparseable SSE data chunk: {}", data);
799 continue;
800 }
801 };
802
803 if let Some(usage) = chunk.usage {
807 let usage = TokenUsage::from(usage);
808 if !usage.is_empty() {
809 yield super::LlmStreamEvent::Usage(usage);
810 }
811 }
812
813 for choice in chunk.choices {
814 if let Some(reasoning) = choice.delta.reasoning.or(choice.delta.reasoning_content)
815 && !reasoning.is_empty()
816 {
817 yield super::LlmStreamEvent::Reasoning(reasoning);
818 }
819
820 if let Some(content) = choice.delta.content
821 && !content.is_empty()
822 {
823 yield super::LlmStreamEvent::ContentChunk(content);
824 }
825
826 if let Some(tool_calls) = choice.delta.tool_calls {
827 for call in tool_calls {
828 let idx = call.index;
829
830 let mut new_args = String::new();
831 let mut tool_name = None;
832
833 let entry = pending_tools.entry(idx).or_insert_with(|| {
837 let name = call.function.as_ref().and_then(|f| f.name.clone()).unwrap_or_default();
838 tool_name = Some(name.clone());
839 super::ToolCall {
840 id: call.id.clone().unwrap_or_default(),
841 name,
842 arguments: String::new(),
843 }
844 });
845
846 if let Some(f) = call.function
847 && let Some(args) = f.arguments
848 {
849 new_args = args.clone();
850 entry.arguments.push_str(&args);
851 }
852
853 yield super::LlmStreamEvent::ToolCallChunk {
854 id: entry.id.clone(),
855 name: tool_name,
856 arguments: new_args,
857 };
858 }
859 }
860 }
861 }
862 };
863
864 Ok(Box::pin(stream))
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use crate::{ChatMessage, Reasoning, ReasoningEffort};
872
873 fn provider(dialect: ReasoningDialect, reasoning: Option<Reasoning>) -> OpenAiProvider {
874 OpenAiProvider::new(OpenAiConfig {
875 base_url: "http://localhost/v1".to_string(),
876 model: "test-model".to_string(),
877 api_key: None,
878 extra_headers: Vec::new(),
879 reasoning_dialect: dialect,
880 reasoning,
881 stream_usage: false,
882 })
883 }
884
885 fn request(reasoning: Option<Reasoning>) -> LlmRequest {
886 let request = LlmRequest::new(vec![ChatMessage::user("hi")]);
887 match reasoning {
888 Some(reasoning) => request.reasoning(reasoning),
889 None => request,
890 }
891 }
892
893 fn wire(provider: &OpenAiProvider, request: &LlmRequest) -> serde_json::Value {
897 let body = OpenAiChatRequest {
898 model: provider.config.model.clone(),
899 messages: Vec::new(),
900 temperature: None,
901 max_tokens: None,
902 response_format: None,
903 tools: None,
904 stream: None,
905 stream_options: None,
906 reasoning: None,
907 reasoning_effort: None,
908 }
909 .with_reasoning(provider.reasoning_for(request));
910
911 let mut value = serde_json::to_value(&body).expect("serializes");
912 value
913 .as_object_mut()
914 .expect("an object")
915 .retain(|key, _| key.starts_with("reasoning"));
916 value
917 }
918
919 #[test]
923 fn the_configured_reasoning_applies_when_a_request_asks_for_nothing() {
924 let provider = provider(ReasoningDialect::OpenRouter, Some(Reasoning::Off));
925 assert_eq!(
926 wire(&provider, &request(None)),
927 serde_json::json!({ "reasoning": { "enabled": false } })
928 );
929 }
930
931 #[test]
934 fn a_request_overrides_the_configured_reasoning() {
935 let provider = provider(ReasoningDialect::OpenRouter, Some(Reasoning::Off));
936 assert_eq!(
937 wire(
938 &provider,
939 &request(Some(Reasoning::Effort(ReasoningEffort::High)))
940 ),
941 serde_json::json!({ "reasoning": { "effort": "high", "enabled": true } })
942 );
943 }
944
945 #[test]
946 fn a_budget_is_sent_as_a_reasoning_token_cap() {
947 let provider = provider(ReasoningDialect::OpenRouter, None);
948 assert_eq!(
949 wire(&provider, &request(Some(Reasoning::Budget(2000)))),
950 serde_json::json!({ "reasoning": { "max_tokens": 2000, "enabled": true } })
951 );
952 }
953
954 #[test]
957 fn the_openai_dialect_sends_a_bare_effort_string() {
958 let provider = provider(ReasoningDialect::OpenAi, None);
959 assert_eq!(
960 wire(
961 &provider,
962 &request(Some(Reasoning::Effort(ReasoningEffort::Medium)))
963 ),
964 serde_json::json!({ "reasoning_effort": "medium" })
965 );
966 }
967
968 #[test]
973 fn turning_thinking_off_is_asked_for_as_none() {
974 let provider = provider(ReasoningDialect::OpenAi, Some(Reasoning::Off));
975 assert_eq!(
976 wire(&provider, &request(None)),
977 serde_json::json!({ "reasoning_effort": "none" })
978 );
979 }
980
981 #[test]
984 fn a_token_budget_has_no_openai_spelling_and_is_not_sent() {
985 let provider = provider(ReasoningDialect::OpenAi, None);
986 assert_eq!(
987 wire(&provider, &request(Some(Reasoning::Budget(2000)))),
988 serde_json::json!({}),
989 "nothing may be sent for a setting this API cannot express"
990 );
991 }
992
993 #[test]
996 fn only_one_dialect_reaches_the_body() {
997 for dialect in [ReasoningDialect::OpenRouter, ReasoningDialect::OpenAi] {
998 let provider = provider(dialect, None);
999 let sent = wire(
1000 &provider,
1001 &request(Some(Reasoning::Effort(ReasoningEffort::Low))),
1002 );
1003 assert_eq!(
1004 sent.as_object().expect("an object").len(),
1005 1,
1006 "exactly one reasoning field for {dialect:?}, got {sent}"
1007 );
1008 }
1009 }
1010
1011 #[test]
1015 fn a_model_that_refused_the_parameter_is_not_asked_again() {
1016 let provider = provider(
1017 ReasoningDialect::OpenAi,
1018 Some(Reasoning::Effort(ReasoningEffort::High)),
1019 );
1020 assert_eq!(
1021 wire(&provider, &request(None)),
1022 serde_json::json!({ "reasoning_effort": "high" })
1023 );
1024
1025 provider.reasoning_support.record_refusal();
1026 assert_eq!(
1027 wire(&provider, &request(None)),
1028 serde_json::json!({}),
1029 "a refusal is remembered, not rediscovered per call"
1030 );
1031 }
1032
1033 #[test]
1036 fn the_refusal_is_shared_across_clones() {
1037 let provider = provider(
1038 ReasoningDialect::OpenAi,
1039 Some(Reasoning::Effort(ReasoningEffort::High)),
1040 );
1041 let clone = provider.clone();
1042 provider.reasoning_support.record_refusal();
1043 assert_eq!(wire(&clone, &request(None)), serde_json::json!({}));
1044 }
1045
1046 #[test]
1047 fn nothing_is_sent_when_nobody_asked() {
1048 for dialect in [ReasoningDialect::OpenRouter, ReasoningDialect::OpenAi] {
1049 let provider = provider(dialect, None);
1050 assert_eq!(
1051 wire(&provider, &request(None)),
1052 serde_json::json!({}),
1053 "for {dialect:?}"
1054 );
1055 }
1056 }
1057}