Skip to main content

a2a_llm/
openai.rs

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/// Configuration for the OpenAI-compatible AI client
12#[derive(Debug, Clone)]
13pub struct OpenAiConfig {
14    pub base_url: String,
15    pub model: String,
16    pub api_key: Option<String>,
17    /// Extra HTTP headers attached to every request. Kept provider-agnostic so
18    /// the OpenAI-compatible adapter carries e.g. OpenRouter's `HTTP-Referer` /
19    /// `X-Title` attribution headers without knowing what they mean.
20    pub extra_headers: Vec<(String, String)>,
21    /// How this endpoint spells the reasoning parameter. The wire dialect is the
22    /// adapter's business, not something every caller should have to ask about
23    /// first.
24    pub reasoning_dialect: ReasoningDialect,
25    /// Reasoning applied to requests that don't ask for their own — the model's
26    /// setting, configured where the model is. `None` sends nothing.
27    pub reasoning: Option<Reasoning>,
28    /// Whether this endpoint accepts `stream_options.include_usage`, which is
29    /// what makes a streaming response report what it cost.
30    ///
31    /// Opt-in rather than always-on: OpenAI and OpenRouter take it, but local
32    /// OpenAI-compatible servers vary, and one that rejects unknown parameters
33    /// fails the whole call. A non-streaming response reports usage either way,
34    /// so this only gates the streaming path.
35    pub stream_usage: bool,
36}
37
38/// Which reasoning parameter an OpenAI-compatible endpoint takes.
39///
40/// The two dialects are not interchangeable: OpenRouter's is a `reasoning`
41/// object that also carries a token budget, OpenAI's is a bare
42/// `reasoning_effort` string with no budget at all.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub enum ReasoningDialect {
45    /// OpenRouter's unified `reasoning` object, which it normalizes per model
46    /// and ignores where a model cannot reason. See
47    /// <https://openrouter.ai/docs/use-cases/reasoning-tokens>.
48    OpenRouter,
49    /// OpenAI's `reasoning_effort` string, which is what the compatible servers
50    /// copied. Whether a *model* accepts it is only known from the 400 it
51    /// answers with, which is what [`ReasoningSupport`] is for.
52    #[default]
53    OpenAi,
54}
55
56/// OpenAI's `reasoning_effort` for a requested [`Reasoning`], or `None` when the
57/// setting has no spelling on that API.
58///
59/// [`Reasoning::Budget`] is the gap: Chat Completions has no reasoning-token cap
60/// field, so a budget cannot be asked for there at all. Public so provider
61/// selection can report that drop before any request is made, rather than
62/// discovering it per call.
63///
64/// `Reasoning::Off` maps to `none`, which only models from gpt-5.1 on accept —
65/// an older model refuses it, the request is retried without it, and a model
66/// that does not reason satisfies "off" anyway.
67pub 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
75/// Default base URL for the OpenRouter API (OpenAI-compatible surface).
76pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
77
78/// OpenAI's own endpoint. Named because it is the one OpenAI-compatible URL
79/// whose parameter support is known rather than guessed — see
80/// [`OpenAiConfig::stream_usage`].
81pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
82
83/// Model used when neither a config nor `OPENROUTER_MODEL` names one. Shared by
84/// both paths so they cannot default differently.
85pub 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    /// Read an OpenAI-compatible config from `env`.
93    ///
94    /// Infallible: an OpenAI-compatible endpoint may legitimately want no key at
95    /// all (a local Ollama), so there is nothing here that can be missing.
96    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            // This path defaults to a local server (Ollama), which is exactly
111            // the population that varies on `stream_options`.
112            stream_usage: false,
113        }
114    }
115
116    /// Build an OpenRouter config (OpenAI-compatible) from explicit values.
117    ///
118    /// `base_url` defaults to [`OPENROUTER_BASE_URL`] when `None`. The optional
119    /// `http_referer` / `x_title` become OpenRouter attribution headers and are
120    /// only sent when provided.
121    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    /// Read OpenRouter config from the environment.
147    ///
148    /// `OPENROUTER_API_KEY` is required; the rest fall back to defaults:
149    /// `OPENROUTER_MODEL` (`z-ai/glm-4.6`), `OPENROUTER_API_BASE_URL`
150    /// ([`OPENROUTER_BASE_URL`]), plus optional `OPENROUTER_HTTP_REFERER` /
151    /// `OPENROUTER_X_TITLE` attribution headers and `OPENROUTER_REASONING`
152    /// (`off`, `low`, `medium`, `high`, or a token budget — see [`Reasoning`]).
153    /// An unreadable `OPENROUTER_REASONING` is an error rather than a default,
154    /// because the value costs money in both directions.
155    pub fn openrouter_from_env() -> Result<Self, String> {
156        Self::openrouter_from_lookup(Env::os())
157    }
158
159    /// Read an OpenRouter config from `env`. See [`Self::openrouter_from_env`].
160    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    /// Asks a streaming response to report usage in its final chunk. Only sent
211    /// when `OpenAiConfig::stream_usage` says the endpoint accepts it.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    stream_options: Option<StreamOptions>,
214    /// OpenRouter's unified reasoning control. At most one of this and
215    /// `reasoning_effort` is ever set — see [`ReasoningParam`].
216    #[serde(skip_serializing_if = "Option::is_none")]
217    reasoning: Option<OpenRouterReasoning>,
218    /// OpenAI's reasoning control.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    reasoning_effort: Option<&'static str>,
221}
222
223impl OpenAiChatRequest {
224    /// Put `param` on the request in whichever field its dialect uses.
225    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    /// Whether this request asks for reasoning at all, in either dialect.
235    fn asks_for_reasoning(&self) -> bool {
236        self.reasoning.is_some() || self.reasoning_effort.is_some()
237    }
238
239    /// Drop the reasoning parameter, for the retry after an endpoint refuses it.
240    fn clear_reasoning(&mut self) {
241        self.reasoning = None;
242        self.reasoning_effort = None;
243    }
244}
245
246/// The reasoning parameter as one endpoint's dialect spells it.
247enum ReasoningParam {
248    /// OpenRouter's `reasoning` object.
249    Unified(OpenRouterReasoning),
250    /// OpenAI's `reasoning_effort` string.
251    Effort(&'static str),
252}
253
254#[derive(Debug, Serialize)]
255struct StreamOptions {
256    include_usage: bool,
257}
258
259/// OpenAI's `usage` block. Present on every non-streaming response, and on the
260/// final streaming chunk when `stream_options.include_usage` was sent.
261#[derive(Debug, Deserialize)]
262struct OpenAiUsage {
263    prompt_tokens: Option<u32>,
264    completion_tokens: Option<u32>,
265    total_tokens: Option<u32>,
266    /// OpenAI/OpenRouter break reasoning out here; absent on most others.
267    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/// OpenRouter's `reasoning` request object (see
289/// <https://openrouter.ai/docs/use-cases/reasoning-tokens>).
290#[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    /// Reasoning-model thinking, as normalized by OpenRouter. Response-only.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    reasoning: Option<String>,
342    /// Raw Zhipu/GLM reasoning field (used when not going through OpenRouter's
343    /// normalization). Response-only.
344    #[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    /// Empty on the final usage-only chunk, which is why this defaults rather
376    /// than failing the parse.
377    #[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    /// Whether this endpoint's model has already refused the reasoning
415    /// parameter. Shared across clones.
416    reasoning_support: ReasoningSupport,
417}
418
419/// The one word every message about a refused `reasoning` / `reasoning_effort`
420/// contains, in either dialect.
421const 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    /// What reasoning parameter this request carries, if any.
438    ///
439    /// The request's own setting wins over the model's configured default. A
440    /// budget is dropped on the OpenAI dialect, which has no field for one, and
441    /// so is everything once the model has refused the parameter — the point of
442    /// remembering that is not to keep paying a round trip to learn it again.
443    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    /// POST `body`, with the configured key and attribution headers.
465    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    /// Read a failed response's body and turn it into the error it becomes.
487    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    /// POST `body`, and if the model refuses the reasoning parameter it carries,
498    /// send it once more without one.
499    ///
500    /// Which models take `reasoning_effort` is not knowable from the endpoint,
501    /// so the alternative to this is a model-name table that is wrong about
502    /// every model released after it was written. Nothing was generated on a
503    /// 400, so the retry costs a round trip and no tokens, and only the first
504    /// refused call pays it.
505    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        // Read here rather than in `api_error`, because the decision to retry is
517        // made from this body.
518        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            // Dropping it fixed the call, so the parameter was the problem and
542            // this endpoint gets no more of them. A retry that fails the same
543            // way says the 400 was about something else, and remembering it
544            // would disable reasoning for the rest of the process over an
545            // unrelated failure.
546            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            // Track partial tool calls by index
770            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                    // Flush any pending tool calls before exiting
784                    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                        // Sometimes providers send non-JSON ping events, just ignore if parsing fails
798                        debug!("Skipping unparseable SSE data chunk: {}", data);
799                        continue;
800                    }
801                };
802
803                // Arrives in the final chunk, which carries no choices. Emitted
804                // before the loop below so a chunk that somehow carries both
805                // still reports content first.
806                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                            // If we see a new tool call but haven't flushed a previous one, it's possible
834                            // the previous one is done. Let's not flush immediately unless we are sure it's done.
835                            // OpenAI gives us tool calls grouped by index over time.
836                            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    /// Whatever the request body carries about reasoning, in whichever dialect —
894    /// serialized, so the field names and the "send nothing" case are the wire's
895    /// and not a restatement of the mapping.
896    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    /// The configured model's setting applies to every request that doesn't
920    /// speak for itself — that is what makes `[llm] reasoning` a property of the
921    /// model rather than something each handler has to remember to pass.
922    #[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    /// …and a request that does ask overrides it, so a caller with a reason
932    /// (`complex_agent` streaming its thinking) is not overruled by config.
933    #[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    /// OpenAI's own parameter is a bare string beside the messages, not an
955    /// object — sending OpenRouter's shape there is a 400.
956    #[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    /// `off` has a spelling on this API too, and it is the one an operator with
969    /// a small fast model wants. Only gpt-5.1 and later accept it; an older
970    /// model refuses it and the request is retried without it, which leaves a
971    /// non-reasoning model doing what was asked anyway.
972    #[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    /// Chat Completions has no reasoning-token cap at all, so a budget cannot be
982    /// asked for — and a request carrying one must still be answerable.
983    #[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    /// The two dialects are mutually exclusive: a body carrying both is a 400 on
994    /// either endpoint.
995    #[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    /// Whether a model takes the parameter is only knowable from the 400 it
1012    /// answers with. Once one has come back, sending it again would buy a wasted
1013    /// round trip on every call for the life of the process.
1014    #[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    /// The memory is shared by clones: a provider is cloned per handler and they
1034    /// all call the same model.
1035    #[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}