Skip to main content

aither_openai/
client.rs

1use crate::PROVIDER_NAME;
2use crate::{
3    DEEPSEEK_BASE_URL, DEFAULT_AUDIO_FORMAT, DEFAULT_AUDIO_MODEL, DEFAULT_AUDIO_VOICE,
4    DEFAULT_BASE_URL, DEFAULT_EMBEDDING_DIM, DEFAULT_EMBEDDING_MODEL, DEFAULT_IMAGE_MODEL,
5    DEFAULT_MODEL, DEFAULT_MODERATION_MODEL, DEFAULT_TRANSCRIPTION_MODEL, OPENROUTER_BASE_URL,
6    error::OpenAIError,
7    request::{
8        ChatCompletionRequest, ChatMessagePayload, ParameterSnapshot, ResponsesInputItem,
9        ResponsesRequest, ResponsesTool, ToolPayload, convert_responses_tools, convert_tools,
10        responses_tool_choice, to_chat_messages, to_responses_input,
11    },
12    response::{
13        ChatCompletionChunk, ChatCompletionUsage, DeltaToolCall, ResponsesOutputItem,
14        ResponsesStreamEvent, ResponsesUsage, should_skip_event,
15    },
16};
17use aither_core::{
18    LanguageModel,
19    llm::{
20        Event, LLMRequest, ReasoningState, ToolCall, Usage,
21        model::{Ability, Profile as ModelProfile, ToolChoice},
22        oneshot,
23    },
24};
25use futures_core::Stream;
26use futures_lite::StreamExt;
27use std::{collections::HashMap, future::Future, sync::Arc, time::Duration};
28use zenwave::{Client, client, header};
29
30/// Configuration for request retry behavior.
31#[derive(Debug, Clone)]
32pub struct RetryConfig {
33    /// Maximum number of retry attempts (0 = no retries).
34    pub max_retries: u32,
35    /// Initial delay before first retry.
36    pub initial_delay: Duration,
37    /// Maximum delay between retries.
38    pub max_delay: Duration,
39    /// Multiplier for exponential backoff.
40    pub backoff_multiplier: f64,
41}
42
43impl Default for RetryConfig {
44    fn default() -> Self {
45        Self {
46            max_retries: 3,
47            initial_delay: Duration::from_millis(500),
48            max_delay: Duration::from_secs(30),
49            backoff_multiplier: 2.0,
50        }
51    }
52}
53
54impl RetryConfig {
55    /// Create a config with no retries.
56    #[must_use]
57    pub fn none() -> Self {
58        Self {
59            max_retries: 0,
60            ..Default::default()
61        }
62    }
63
64    /// Calculate delay for a given attempt number (0-indexed).
65    #[allow(
66        clippy::cast_possible_truncation,
67        clippy::cast_possible_wrap,
68        clippy::cast_precision_loss,
69        clippy::cast_sign_loss
70    )]
71    fn delay_for_attempt(&self, attempt: u32) -> Duration {
72        let delay_ms =
73            self.initial_delay.as_millis() as f64 * self.backoff_multiplier.powi(attempt as i32);
74        let delay = Duration::from_millis(delay_ms as u64);
75        delay.min(self.max_delay)
76    }
77}
78
79fn is_retryable_json_error(error: &serde_json::Error) -> bool {
80    matches!(error.classify(), serde_json::error::Category::Eof)
81}
82
83/// Check if an error is retryable.
84fn is_retryable_error(err: &OpenAIError) -> bool {
85    match err {
86        OpenAIError::Http(_)
87        | OpenAIError::Body(_)
88        | OpenAIError::Stream(_)
89        | OpenAIError::RateLimit { .. }
90        | OpenAIError::ServerError { .. }
91        | OpenAIError::Timeout => true,
92        // API and decode errors are generally not retryable.
93        OpenAIError::Api(_) | OpenAIError::Decode(_) => false,
94        // EOF while parsing JSON is typically a truncated/empty upstream body.
95        OpenAIError::Json(error) => is_retryable_json_error(error),
96    }
97}
98
99/// Get retry delay for an error, respecting Retry-After header for rate limits.
100fn get_retry_delay(err: &OpenAIError, attempt: u32, config: &RetryConfig) -> Duration {
101    if let OpenAIError::RateLimit {
102        retry_after: Some(delay),
103        ..
104    } = err
105    {
106        // Respect Retry-After header, but cap at max_delay
107        return (*delay).min(config.max_delay);
108    }
109    config.delay_for_attempt(attempt)
110}
111
112/// Sleep for the given duration (runtime-agnostic).
113async fn sleep(duration: Duration) {
114    #[cfg(not(target_arch = "wasm32"))]
115    {
116        async_io::Timer::after(duration).await;
117    }
118    #[cfg(target_arch = "wasm32")]
119    {
120        let _ = duration;
121    }
122}
123
124fn map_zenwave_error(error: zenwave::Error) -> OpenAIError {
125    if matches!(error, zenwave::Error::Timeout) {
126        OpenAIError::Timeout
127    } else {
128        OpenAIError::Http(error)
129    }
130}
131
132async fn request_with_timeout<T>(
133    timeout: Duration,
134    fut: impl Future<Output = Result<T, zenwave::Error>>,
135) -> Result<T, OpenAIError> {
136    #[cfg(not(target_arch = "wasm32"))]
137    {
138        futures_lite::future::or(
139            async move { fut.await.map_err(map_zenwave_error) },
140            async move {
141                async_io::Timer::after(timeout).await;
142                Err(OpenAIError::Timeout)
143            },
144        )
145        .await
146    }
147    #[cfg(target_arch = "wasm32")]
148    {
149        // The browser timer is not `Send`, and `LanguageModel` requires the
150        // futures built on top of this one to be. Run the timer on the JS event
151        // loop where it belongs and wait on a channel, which is `Send`.
152        let millis = timeout.as_millis().min(u128::from(u32::MAX)) as u32;
153        let (expired_tx, expired_rx) = async_channel::bounded::<()>(1);
154        wasm_bindgen_futures::spawn_local(async move {
155            gloo_timers::future::TimeoutFuture::new(millis).await;
156            let _ = expired_tx.send(()).await;
157        });
158
159        futures_lite::future::or(
160            async move { fut.await.map_err(map_zenwave_error) },
161            async move {
162                // A closed channel means the timer task was dropped, which can
163                // only happen once the whole request is being torn down.
164                let _ = expired_rx.recv().await;
165                Err(OpenAIError::Timeout)
166            },
167        )
168        .await
169    }
170}
171
172/// Result of attempting to establish an SSE stream.
173type SseStreamResult = Result<zenwave::sse::SseStream, OpenAIError>;
174
175/// Attempt to make an SSE request with retry logic.
176///
177/// Returns a streaming SSE connection on success, or the last error on failure.
178async fn sse_request_with_retry<F, Fut>(cfg: &Config, make_request: F) -> SseStreamResult
179where
180    F: Fn() -> Fut,
181    Fut: std::future::Future<Output = SseStreamResult>,
182{
183    let retry_config = &cfg.retry;
184    let mut attempt = 0;
185
186    loop {
187        match make_request().await {
188            Ok(stream) => return Ok(stream),
189            Err(err) => {
190                // Check if we should retry
191                if attempt < retry_config.max_retries && is_retryable_error(&err) {
192                    let delay = get_retry_delay(&err, attempt, retry_config);
193                    tracing::warn!(
194                        attempt = attempt + 1,
195                        max_retries = retry_config.max_retries,
196                        delay_ms = delay.as_millis(),
197                        error = %err,
198                        "Request failed, retrying"
199                    );
200                    sleep(delay).await;
201                    attempt += 1;
202                } else {
203                    return Err(err);
204                }
205            }
206        }
207    }
208}
209
210/// `OpenAI` model backed by the Responses API by default, with legacy
211/// `chat.completions` support for compatibility.
212#[derive(Clone, Debug)]
213pub struct OpenAI {
214    inner: Arc<Config>,
215}
216
217/// Selects which `OpenAI` API surface to use.
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub enum ApiKind {
220    /// The recommended Responses API.
221    Responses,
222    /// Legacy Chat Completions API (deprecated by `OpenAI`).
223    ChatCompletions,
224}
225
226impl OpenAI {
227    /// Create a new client using the provided API key and default model.
228    pub fn new(api_key: impl Into<String>) -> Self {
229        Self::builder(api_key).build()
230    }
231
232    /// Create a client configured for [`Deepseek`](https://api-docs.deepseek.com)'s OpenAI-compatible endpoint.
233    pub fn deepseek(api_key: impl Into<String>) -> Self {
234        Self::builder(api_key).base_url(DEEPSEEK_BASE_URL).build()
235    }
236
237    /// Create a client configured for [`OpenRouter`](https://openrouter.ai)'s OpenAI-compatible endpoint.
238    pub fn openrouter(api_key: impl Into<String>) -> Self {
239        Self::builder(api_key).base_url(OPENROUTER_BASE_URL).build()
240    }
241
242    /// Start building an [`OpenAI`] client with custom configuration.
243    #[must_use]
244    pub fn builder(api_key: impl Into<String>) -> Builder {
245        Builder::new(api_key)
246    }
247
248    /// Override the default chat model in-place.
249    #[must_use]
250    pub fn with_model(mut self, model: impl Into<String>) -> Self {
251        Arc::make_mut(&mut self.inner).chat_model = sanitize_model(model);
252        self
253    }
254
255    /// Override the REST base URL (useful for OpenAI-compatible endpoints).
256    #[must_use]
257    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
258        Arc::make_mut(&mut self.inner).base_url = base_url.into();
259        self
260    }
261
262    /// Select which `OpenAI` API to call.
263    #[must_use]
264    pub fn with_api(mut self, api: ApiKind) -> Self {
265        Arc::make_mut(&mut self.inner).api_kind = api;
266        self
267    }
268
269    /// Use the recommended Responses API.
270    #[must_use]
271    pub fn with_responses_api(self) -> Self {
272        self.with_api(ApiKind::Responses)
273    }
274
275    /// Use the legacy Chat Completions API (deprecated by `OpenAI`).
276    #[must_use]
277    pub fn with_chat_completions_api(self) -> Self {
278        self.with_api(ApiKind::ChatCompletions)
279    }
280
281    /// Send deprecated `max_tokens` alongside `max_completion_tokens` for compatibility.
282    ///
283    /// `OpenAI` deprecates `max_tokens` and it is incompatible with reasoning models.
284    #[must_use]
285    pub fn with_legacy_max_tokens(mut self, enabled: bool) -> Self {
286        Arc::make_mut(&mut self.inner).legacy_max_tokens = enabled;
287        self
288    }
289
290    /// Override the embeddings model identifier.
291    #[must_use]
292    pub fn with_embedding_model(mut self, model: impl Into<String>) -> Self {
293        let model = sanitize_model(model);
294        let cfg = Arc::make_mut(&mut self.inner);
295        cfg.embedding_model = model;
296        if let Some(dim) = infer_embedding_dim(&cfg.embedding_model) {
297            cfg.embedding_dimensions = dim;
298        }
299        self
300    }
301
302    /// Override the embedding dimension (defaults depend on model).
303    #[must_use]
304    pub fn with_embedding_dimensions(mut self, dimensions: usize) -> Self {
305        Arc::make_mut(&mut self.inner).embedding_dimensions = dimensions;
306        self
307    }
308
309    /// Override the image generation model identifier.
310    #[must_use]
311    pub fn with_image_model(mut self, model: impl Into<String>) -> Self {
312        Arc::make_mut(&mut self.inner).image_model = sanitize_model(model);
313        self
314    }
315
316    /// Override the default text-to-speech model identifier.
317    #[must_use]
318    pub fn with_audio_model(mut self, model: impl Into<String>) -> Self {
319        Arc::make_mut(&mut self.inner).audio_model = sanitize_model(model);
320        self
321    }
322
323    /// Set the default text-to-speech voice (e.g., `alloy`).
324    #[must_use]
325    pub fn with_audio_voice(mut self, voice: impl Into<String>) -> Self {
326        Arc::make_mut(&mut self.inner).audio_voice = voice.into();
327        self
328    }
329
330    /// Set the preferred audio format (`mp3`, `wav`, `flac`).
331    #[must_use]
332    pub fn with_audio_format(mut self, format: impl Into<String>) -> Self {
333        Arc::make_mut(&mut self.inner).audio_format = format.into();
334        self
335    }
336
337    /// Override the transcription model identifier.
338    #[must_use]
339    pub fn with_transcription_model(mut self, model: impl Into<String>) -> Self {
340        Arc::make_mut(&mut self.inner).transcription_model = sanitize_model(model);
341        self
342    }
343
344    /// Override the moderation model identifier.
345    #[must_use]
346    pub fn with_moderation_model(mut self, model: impl Into<String>) -> Self {
347        Arc::make_mut(&mut self.inner).moderation_model = sanitize_model(model);
348        self
349    }
350
351    pub(crate) fn config(&self) -> Arc<Config> {
352        self.inner.clone()
353    }
354}
355
356impl LanguageModel for OpenAI {
357    type Error = OpenAIError;
358
359    #[allow(clippy::too_many_lines)]
360    fn respond(
361        &self,
362        request: LLMRequest,
363    ) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
364        let cfg = self.inner.clone();
365        let (messages, parameters, tool_defs) = request.into_parts();
366        let tool_defs = filter_tool_definitions(tool_defs, &parameters.tool_choice);
367        let mut snapshot = ParameterSnapshot::from(&parameters);
368        snapshot.legacy_max_tokens = cfg.legacy_max_tokens;
369        let has_attachments = messages.iter().any(|msg| !msg.attachments().is_empty());
370        let has_audio = messages.iter().any(|message| {
371            message
372                .attachments()
373                .iter()
374                .any(|attachment| attachment.media_type().as_ref().starts_with("audio/"))
375        });
376        let effective_api_kind = if cfg.api_kind == ApiKind::Responses && has_audio {
377            ApiKind::ChatCompletions
378        } else {
379            cfg.api_kind
380        };
381
382        async_stream::stream! {
383            if parameters.cache.claude.is_some() || parameters.cache.gemini.is_some() {
384                yield Err(OpenAIError::Api(
385                    "OpenAI provider only accepts cache.openai settings".to_string(),
386                ));
387                return;
388            }
389
390            if effective_api_kind == ApiKind::ChatCompletions
391                && messages.iter().flat_map(aither_core::llm::Message::attachments).any(|attachment| {
392                    let media_type = attachment.media_type().as_ref();
393                    !media_type.starts_with("image/") && !media_type.starts_with("audio/")
394                })
395            {
396                yield Err(OpenAIError::Api(
397                    "Chat Completions accepts only image and audio attachments".to_string(),
398                ));
399                return;
400            }
401
402            if effective_api_kind == ApiKind::Responses
403                && messages.iter().flat_map(aither_core::llm::Message::attachments).any(|attachment| {
404                    attachment.media_type().as_ref().starts_with("video/")
405                })
406            {
407                yield Err(OpenAIError::Api(
408                    "OpenAI Responses API does not support video attachments".to_string(),
409                ));
410                return;
411            }
412
413            if effective_api_kind == ApiKind::ChatCompletions
414                && (!snapshot.openai_tools.is_empty() || snapshot.websearch || snapshot.code_execution)
415            {
416                yield Err(OpenAIError::Api(
417                    "OpenAI native hosted tools require the Responses API".to_string(),
418                ));
419                return;
420            }
421
422            let messages = if has_attachments && effective_api_kind == ApiKind::Responses {
423                #[cfg(not(target_arch = "wasm32"))]
424                {
425                    match crate::attachments::resolve_messages(&cfg, messages).await {
426                        Ok(resolved) => resolved,
427                        Err(err) => {
428                            yield Err(err);
429                            return;
430                        }
431                    }
432                }
433                #[cfg(target_arch = "wasm32")]
434                {
435                    yield Err(OpenAIError::Api(
436                        "file:// attachments are not supported on wasm32 targets".to_string(),
437                    ));
438                    return;
439                }
440            } else {
441                messages
442            };
443
444            match effective_api_kind {
445                ApiKind::ChatCompletions => {
446                    let payload_messages = match to_chat_messages(&messages).await {
447                        Ok(payload) => payload,
448                        Err(error) => {
449                            yield Err(error);
450                            return;
451                        }
452                    };
453                    let openai_tools = if tool_defs.is_empty() {
454                        None
455                    } else {
456                        Some(convert_tools(tool_defs))
457                    };
458
459                    let mut events = chat_completions_stream(
460                        cfg,
461                        payload_messages,
462                        snapshot,
463                        openai_tools,
464                    );
465
466                    while let Some(event) = events.next().await {
467                        yield event;
468                    }
469                }
470                ApiKind::Responses => {
471                    let responses_input = match to_responses_input(&messages) {
472                        Ok(input) => input,
473                        Err(err) => {
474                            yield Err(err);
475                            return;
476                        }
477                    };
478
479                    let mut events = responses_stream(
480                        cfg,
481                        responses_input,
482                        snapshot,
483                        tool_defs,
484                    );
485
486                    while let Some(event) = events.next().await {
487                        yield event;
488                    }
489                }
490            }
491        }
492    }
493
494    fn complete(&self, prefix: &str) -> impl Stream<Item = Result<Event, Self::Error>> + Send {
495        self.respond(oneshot(
496            "Continue the user provided text without additional commentary.",
497            prefix,
498        ))
499    }
500
501    fn profile(&self) -> impl Future<Output = ModelProfile> + Send {
502        let cfg = self.inner.clone();
503        async move {
504            // Try to fetch context window from API, fallback to models database
505            let context_length = match fetch_model_context_length(&cfg).await {
506                Ok(len) => len,
507                Err(e) => {
508                    tracing::debug!("API did not return context_length: {e}");
509                    aither_models::lookup(&cfg.chat_model)
510                        .and_then(aither_models::ModelEntry::max_input_tokens)
511                        .unwrap_or_else(|| {
512                            // Custom/self-hosted models are legitimately absent
513                            // from both the provider API and the registry, so
514                            // assume a conservative context window rather than
515                            // failing the whole conversation.
516                            tracing::warn!(
517                                model = %cfg.chat_model,
518                                assumed_context_length = UNKNOWN_MODEL_CONTEXT_LENGTH,
519                                "model missing context metadata from provider and aither-models"
520                            );
521                            UNKNOWN_MODEL_CONTEXT_LENGTH
522                        })
523                }
524            };
525
526            let mut profile = ModelProfile::new(
527                cfg.chat_model.clone(),
528                "OpenAI",
529                cfg.chat_model.clone(),
530                "OpenAI GPT family model",
531                context_length,
532            )
533            .with_ability(Ability::ToolUse);
534            for ability in &cfg.native_abilities {
535                if !profile.abilities.contains(ability) {
536                    profile.abilities.push(*ability);
537                }
538            }
539            profile
540        }
541    }
542}
543
544/// Fetch context window size from the models API.
545/// Context window assumed for models with no metadata from either the
546/// provider API or the aither-models registry (custom/self-hosted models).
547/// Conservative enough to be safe for compaction budgeting on any modern
548/// OpenAI-compatible backend.
549const UNKNOWN_MODEL_CONTEXT_LENGTH: u32 = 32_768;
550
551async fn fetch_model_context_length(cfg: &Config) -> Result<u32, OpenAIError> {
552    use crate::response::ModelsListResponse;
553
554    let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
555    let mut backend = client();
556    let mut builder = backend
557        .get(&url)
558        .map_err(OpenAIError::Http)?
559        .header(
560            header::AUTHORIZATION.as_str(),
561            format!("Bearer {}", cfg.api_key),
562        )
563        .map_err(OpenAIError::Http)?;
564    for (name, value) in &cfg.extra_headers {
565        builder = builder
566            .header(name.clone(), value.clone())
567            .map_err(OpenAIError::Http)?;
568    }
569    let response: ModelsListResponse =
570        request_with_timeout(cfg.request_timeout, builder.json()).await?;
571
572    // Find matching model
573    let mut model_found = false;
574    for model in response.data {
575        if model.id == cfg.chat_model {
576            model_found = true;
577            if let Some(ctx) = model.context_length.or(model.max_tokens) {
578                tracing::debug!(
579                    "Fetched context_length={} for model '{}'",
580                    ctx,
581                    cfg.chat_model
582                );
583                return Ok(ctx);
584            }
585        }
586    }
587
588    // More specific error message
589    if model_found {
590        Err(OpenAIError::Api(format!(
591            "Model '{}' found but missing context_length field (proxy may not support it)",
592            cfg.chat_model
593        )))
594    } else {
595        Err(OpenAIError::Api(format!(
596            "Model '{}' not found in /models response",
597            cfg.chat_model
598        )))
599    }
600}
601
602fn chat_completions_stream(
603    cfg: Arc<Config>,
604    payload_messages: Vec<ChatMessagePayload>,
605    snapshot: ParameterSnapshot,
606    openai_tools: Option<Vec<ToolPayload>>,
607) -> impl Stream<Item = Result<Event, OpenAIError>> + Send + Unpin {
608    Box::pin(chat_completions_stream_inner(
609        cfg,
610        payload_messages,
611        snapshot,
612        openai_tools,
613    ))
614}
615
616/// Accumulated tool call state for streaming.
617#[derive(Debug, Default)]
618struct ToolCallAccumulator {
619    id: Option<String>,
620    name: Option<String>,
621    arguments: String,
622    /// Bytes of `arguments` already emitted as `ToolCallDelta` fragments.
623    emitted: usize,
624    /// Whether the first `ToolCallDelta` announcing id and name went out.
625    announced: bool,
626}
627
628/// Merge one `delta.tool_calls` entry into the accumulator for its index.
629///
630/// Compatible servers are expected to omit `id`/`name` on continuation
631/// deltas; some instead send them again as empty strings. Empty values are
632/// not authoritative — they must not overwrite the id or name captured
633/// from the first delta.
634fn accumulate_tool_call_delta<'a>(
635    tool_calls: &'a mut HashMap<usize, ToolCallAccumulator>,
636    call: &DeltaToolCall,
637) -> &'a mut ToolCallAccumulator {
638    let index = call.index.unwrap_or(0);
639    let acc = tool_calls.entry(index).or_default();
640    if let Some(id) = &call.id
641        && !id.is_empty()
642    {
643        acc.id = Some(id.clone());
644    }
645    if let Some(function) = &call.function {
646        if let Some(name) = &function.name
647            && !name.is_empty()
648        {
649            acc.name = Some(name.clone());
650        }
651        if let Some(args) = &function.arguments {
652            acc.arguments.push_str(args);
653        }
654    }
655    acc
656}
657
658/// Make a chat completions SSE request (single attempt).
659async fn chat_completions_request(
660    cfg: &Config,
661    request: &ChatCompletionRequest,
662) -> SseStreamResult {
663    let endpoint = cfg.request_url("/chat/completions");
664    let mut backend = client();
665
666    let build_result = backend
667        .post(endpoint)
668        .and_then(|b| b.header(header::AUTHORIZATION.as_str(), cfg.request_auth()))
669        .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-openai/0.1"))
670        .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"));
671
672    let mut builder = build_result.map_err(OpenAIError::Http)?;
673
674    if let Some(org) = &cfg.organization {
675        builder = builder
676            .header("OpenAI-Organization", org.clone())
677            .map_err(OpenAIError::Http)?;
678    }
679    for (name, value) in &cfg.extra_headers {
680        builder = builder
681            .header(name.clone(), value.clone())
682            .map_err(OpenAIError::Http)?;
683    }
684
685    request_with_timeout(
686        cfg.request_timeout,
687        builder.json_body(request).map_err(OpenAIError::Http)?.sse(),
688    )
689    .await
690}
691
692#[allow(clippy::too_many_lines)]
693fn chat_completions_stream_inner(
694    cfg: Arc<Config>,
695    payload_messages: Vec<ChatMessagePayload>,
696    snapshot: ParameterSnapshot,
697    openai_tools: Option<Vec<ToolPayload>>,
698) -> impl Stream<Item = Result<Event, OpenAIError>> + Send {
699    let include_reasoning = snapshot.include_reasoning;
700
701    async_stream::stream! {
702        let request = ChatCompletionRequest::new(
703            cfg.chat_model.clone(),
704            payload_messages,
705            &snapshot,
706            openai_tools,
707            true,
708        );
709
710        tracing::debug!(request = %serde_json::to_string_pretty(&request).unwrap_or_default(), "Sending chat completion request");
711
712        // Make request with retry
713        let sse_stream = match sse_request_with_retry(&cfg, || chat_completions_request(&cfg, &request)).await {
714            Ok(stream) => stream,
715            Err(e) => {
716                yield Err(e);
717                return;
718            }
719        };
720        futures_lite::pin!(sse_stream);
721
722        // Accumulate tool calls by index - streaming sends id/name first, then arguments incrementally
723        let mut tool_calls: std::collections::HashMap<usize, ToolCallAccumulator> =
724            std::collections::HashMap::new();
725        let mut usage: Option<Usage> = None;
726        while let Some(event) = sse_stream.next().await {
727            match event {
728                Ok(e) => {
729                    if should_skip_event(&e) {
730                        continue;
731                    }
732                    let data = e.text_data();
733                    if data == "[DONE]" {
734                        continue;
735                    }
736                    tracing::debug!(sse_event = %data, "Received SSE event");
737
738                    // Check for API error response
739                    if let Ok(error_obj) = serde_json::from_str::<serde_json::Value>(data)
740                        && let Some(error) = error_obj.get("error") {
741                            let msg = error.get("message")
742                                .and_then(|m| m.as_str())
743                                .unwrap_or("Unknown API error");
744                            yield Err(OpenAIError::Api(msg.to_string()));
745                            return;
746                        }
747
748                    match serde_json::from_str::<ChatCompletionChunk>(data) {
749                        Ok(chunk) => {
750                            if let Some(chunk_usage) = &chunk.usage {
751                                usage = Some(usage_from_chat_completion(chunk_usage));
752                            }
753                            // Emit text events
754                            for choice in &chunk.choices {
755                                // Check for malformed function call
756                                if let Some(ref reason) = choice.finish_reason
757                                    && reason.contains("malformed_function_call") {
758                                        yield Err(OpenAIError::Api("malformed function call".to_string()));
759                                        return;
760                                    }
761
762                                if let Some(content) = &choice.delta.content
763                                    && !content.is_empty() {
764                                        yield Ok(Event::Text(content.clone()));
765                                    }
766                                // Emit reasoning if enabled
767                                if include_reasoning
768                                    && let Some(reasoning) = &choice.delta.reasoning_content
769                                        && !reasoning.is_empty() {
770                                            yield Ok(Event::Reasoning(reasoning.clone()));
771                                        }
772                                // Accumulate tool calls and emit deltas
773                                if let Some(calls) = &choice.delta.tool_calls {
774                                    for call in calls {
775                                        let acc = accumulate_tool_call_delta(&mut tool_calls, call);
776                                        // Emit incremental fragments for UI
777                                        // progress; the first emission may
778                                        // precede any argument bytes, so the
779                                        // tool name surfaces as soon as both
780                                        // id and name are known.
781                                        if let (Some(id), Some(name)) = (&acc.id, &acc.name) {
782                                            let fragment =
783                                                acc.arguments[acc.emitted..].to_string();
784                                            if !fragment.is_empty() || !acc.announced {
785                                                acc.emitted = acc.arguments.len();
786                                                acc.announced = true;
787                                                yield Ok(Event::ToolCallDelta {
788                                                    id: id.clone(),
789                                                    name: name.clone(),
790                                                    arguments_fragment: fragment,
791                                                });
792                                            }
793                                        }
794                                    }
795                                }
796                            }
797                        }
798                        Err(e) => {
799                            yield Err(OpenAIError::from(e));
800                            return;
801                        }
802                    }
803                }
804                Err(e) => {
805                    yield Err(OpenAIError::from(e));
806                    return;
807                }
808            }
809        }
810
811        // Emit accumulated tool calls at the end
812        let mut sorted_calls: Vec<_> = tool_calls.into_iter().collect();
813        sorted_calls.sort_by_key(|(index, _)| *index);
814        for (_, acc) in sorted_calls {
815            if let (Some(id), Some(name)) = (acc.id, acc.name) {
816                let arguments = if acc.arguments.is_empty() {
817                    serde_json::Value::Object(serde_json::Map::default())
818                } else {
819                    serde_json::from_str(&acc.arguments).unwrap_or_else(|_| {
820                        serde_json::Value::Object(serde_json::Map::default())
821                    })
822                };
823                yield Ok(Event::ToolCall(ToolCall {
824                    id,
825                    name,
826                    arguments,
827                    reasoning_state: None,
828                }));
829            }
830        }
831        if let Some(final_usage) = usage {
832            yield Ok(Event::Usage(final_usage));
833        }
834    }
835}
836
837fn responses_stream(
838    cfg: Arc<Config>,
839    input: Vec<ResponsesInputItem>,
840    snapshot: ParameterSnapshot,
841    tool_defs: Vec<aither_core::llm::tool::ToolDefinition>,
842) -> impl Stream<Item = Result<Event, OpenAIError>> + Send + Unpin {
843    Box::pin(responses_stream_inner(cfg, input, snapshot, tool_defs))
844}
845
846/// Accumulated function call state for Responses API streaming.
847#[derive(Debug, Default)]
848struct FunctionCallAccumulator {
849    item_id: Option<String>,
850    call_id: Option<String>,
851    name: Option<String>,
852    arguments: String,
853    emitted: bool,
854}
855
856fn normalize_response_call_id(call_id: Option<String>, fallback: &str) -> String {
857    match call_id {
858        Some(call_id) if !call_id.trim().is_empty() => call_id,
859        _ => fallback.to_string(),
860    }
861}
862
863fn non_empty_response_id(value: Option<String>) -> Option<String> {
864    value.filter(|value| !value.trim().is_empty())
865}
866
867fn fallback_response_item_id(output_index: usize) -> String {
868    format!("response_output_{output_index}")
869}
870
871fn parse_tool_call_arguments(arguments: &str) -> serde_json::Value {
872    if arguments.is_empty() {
873        serde_json::Value::Object(serde_json::Map::default())
874    } else {
875        serde_json::from_str(arguments)
876            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::default()))
877    }
878}
879
880fn usage_from_chat_completion(raw: &ChatCompletionUsage) -> Usage {
881    Usage {
882        prompt_tokens: raw.prompt_tokens,
883        completion_tokens: raw.completion_tokens,
884        total_tokens: raw.total_tokens,
885        reasoning_tokens: raw
886            .completion_tokens_details
887            .as_ref()
888            .and_then(|details| details.reasoning_tokens),
889        cache_read_tokens: raw
890            .prompt_tokens_details
891            .as_ref()
892            .and_then(|details| details.cached_tokens),
893        cache_write_tokens: None,
894        cost_usd: None,
895        stop_reason: None,
896    }
897}
898
899fn usage_from_responses(raw: &ResponsesUsage) -> Usage {
900    Usage {
901        prompt_tokens: raw.input_tokens,
902        completion_tokens: raw.output_tokens,
903        total_tokens: raw.total_tokens,
904        reasoning_tokens: raw
905            .output_token_details
906            .as_ref()
907            .and_then(|details| details.reasoning_tokens),
908        cache_read_tokens: raw
909            .input_token_details
910            .as_ref()
911            .and_then(|details| details.cached_tokens),
912        cache_write_tokens: None,
913        cost_usd: None,
914        stop_reason: None,
915    }
916}
917
918fn mark_and_emit_done_function_call(
919    function_calls: &mut HashMap<usize, FunctionCallAccumulator>,
920    output_index: usize,
921    item_id: Option<String>,
922    response_id: String,
923    call_id: Option<String>,
924    name: String,
925    arguments: &str,
926) -> ToolCall {
927    let fallback_id = non_empty_response_id(item_id.clone())
928        .or_else(|| non_empty_response_id(Some(response_id.clone())))
929        .unwrap_or_else(|| fallback_response_item_id(output_index));
930    let resolved_call_id = normalize_response_call_id(call_id, &fallback_id);
931
932    let acc = function_calls.entry(output_index).or_default();
933    acc.item_id =
934        non_empty_response_id(item_id).or_else(|| non_empty_response_id(Some(response_id)));
935    acc.call_id = Some(resolved_call_id.clone());
936    acc.name = Some(name.clone());
937    acc.arguments.clear();
938    acc.arguments.push_str(arguments);
939    acc.emitted = true;
940
941    ToolCall {
942        id: resolved_call_id,
943        name,
944        arguments: parse_tool_call_arguments(arguments),
945        reasoning_state: None,
946    }
947}
948
949fn drain_pending_function_calls(
950    function_calls: HashMap<usize, FunctionCallAccumulator>,
951) -> Vec<ToolCall> {
952    function_calls
953        .into_iter()
954        .filter(|(_, acc)| !acc.emitted)
955        .filter_map(|(output_index, acc)| {
956            let name = acc.name?;
957            let fallback_id = acc
958                .item_id
959                .clone()
960                .unwrap_or_else(|| fallback_response_item_id(output_index));
961            let call_id = normalize_response_call_id(acc.call_id, &fallback_id);
962            Some(ToolCall {
963                id: call_id,
964                name,
965                arguments: parse_tool_call_arguments(&acc.arguments),
966                reasoning_state: None,
967            })
968        })
969        .collect()
970}
971
972#[allow(clippy::items_after_statements)]
973fn validate_serialized_responses_request(request: &ResponsesRequest) -> Result<(), OpenAIError> {
974    let request_value = serde_json::to_value(request).map_err(OpenAIError::Json)?;
975    let Some(items) = request_value
976        .get("input")
977        .and_then(serde_json::Value::as_array)
978    else {
979        return Ok(());
980    };
981
982    fn find_blank_call_id(value: &serde_json::Value, path: &str) -> Option<(String, String)> {
983        match value {
984            serde_json::Value::Object(map) => {
985                for (key, child) in map {
986                    let child_path = format!("{path}.{key}");
987                    if key == "call_id"
988                        && let Some(call_id) = child.as_str()
989                        && call_id.trim().is_empty()
990                    {
991                        return Some((child_path, call_id.to_string()));
992                    }
993                    if let Some(found) = find_blank_call_id(child, &child_path) {
994                        return Some(found);
995                    }
996                }
997                None
998            }
999            serde_json::Value::Array(items) => {
1000                for (index, child) in items.iter().enumerate() {
1001                    let child_path = format!("{path}[{index}]");
1002                    if let Some(found) = find_blank_call_id(child, &child_path) {
1003                        return Some(found);
1004                    }
1005                }
1006                None
1007            }
1008            serde_json::Value::Null
1009            | serde_json::Value::Bool(_)
1010            | serde_json::Value::Number(_)
1011            | serde_json::Value::String(_) => None,
1012        }
1013    }
1014
1015    for (index, item) in items.iter().enumerate() {
1016        if let Some((path, call_id)) = find_blank_call_id(item, &format!("input[{index}]")) {
1017            let snippet = serde_json::to_string(item).map_err(OpenAIError::Json)?;
1018            return Err(OpenAIError::Api(format!(
1019                "responses request serialized blank call_id at {path}: {call_id:?}; item={snippet}"
1020            )));
1021        }
1022    }
1023    Ok(())
1024}
1025
1026/// Make a responses API SSE request (single attempt).
1027async fn responses_request(cfg: &Config, request: &ResponsesRequest) -> SseStreamResult {
1028    validate_serialized_responses_request(request)?;
1029    let serialized_request = serde_json::to_string(request).map_err(OpenAIError::Json)?;
1030    let endpoint = cfg.request_url("/responses");
1031    let mut backend = client();
1032
1033    let build_result = backend
1034        .post(endpoint)
1035        .and_then(|b| b.header(header::AUTHORIZATION.as_str(), cfg.request_auth()))
1036        .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-openai/0.1"))
1037        .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"));
1038
1039    let mut builder = build_result.map_err(OpenAIError::Http)?;
1040
1041    if let Some(org) = &cfg.organization {
1042        builder = builder
1043            .header("OpenAI-Organization", org.clone())
1044            .map_err(OpenAIError::Http)?;
1045    }
1046    for (name, value) in &cfg.extra_headers {
1047        builder = builder
1048            .header(name.clone(), value.clone())
1049            .map_err(OpenAIError::Http)?;
1050    }
1051
1052    match request_with_timeout(
1053        cfg.request_timeout,
1054        builder.json_body(request).map_err(OpenAIError::Http)?.sse(),
1055    )
1056    .await
1057    {
1058        Ok(stream) => Ok(stream),
1059        Err(error) => {
1060            if error.to_string().contains("call_id") {
1061                return Err(OpenAIError::Api(format!(
1062                    "{error}\nSerialized responses request: {serialized_request}"
1063                )));
1064            }
1065            Err(error)
1066        }
1067    }
1068}
1069
1070#[allow(clippy::too_many_lines, clippy::collapsible_match)]
1071fn responses_stream_inner(
1072    cfg: Arc<Config>,
1073    input: Vec<ResponsesInputItem>,
1074    snapshot: ParameterSnapshot,
1075    tool_defs: Vec<aither_core::llm::tool::ToolDefinition>,
1076) -> impl Stream<Item = Result<Event, OpenAIError>> + Send {
1077    let include_reasoning = snapshot.include_reasoning;
1078
1079    async_stream::stream! {
1080        let mut response_tools = convert_responses_tools(tool_defs);
1081        let allow_builtins = matches!(snapshot.tool_choice, ToolChoice::Auto | ToolChoice::Required);
1082        if allow_builtins {
1083            let native_tools = snapshot.openai_tools.clone();
1084            if snapshot.websearch || native_tools.web_search.is_some() {
1085                let tool = native_tools.web_search.unwrap_or_default();
1086                response_tools.push(ResponsesTool::from(tool));
1087            }
1088            for tool in native_tools.file_search {
1089                response_tools.push(ResponsesTool::from(tool));
1090            }
1091            if snapshot.code_execution || native_tools.code_interpreter.is_some() {
1092                let tool = native_tools.code_interpreter.unwrap_or_default();
1093                response_tools.push(ResponsesTool::from(tool));
1094            }
1095            if let Some(tool) = native_tools.image_generation {
1096                response_tools.push(ResponsesTool::from(tool));
1097            }
1098            for tool in native_tools.mcp {
1099                response_tools.push(ResponsesTool::from(tool));
1100            }
1101            if let Some(tool) = native_tools.computer_use {
1102                response_tools.push(ResponsesTool::from(tool));
1103            }
1104        }
1105        let response_tools = if response_tools.is_empty() {
1106            None
1107        } else {
1108            Some(response_tools)
1109        };
1110        let has_tools = response_tools.is_some();
1111        let tool_choice = responses_tool_choice(&snapshot, has_tools);
1112
1113        let mut request = ResponsesRequest::new(
1114            cfg.chat_model.clone(),
1115            input,
1116            &snapshot,
1117            response_tools,
1118            tool_choice,
1119            true, // stream: true
1120        );
1121        request.store = cfg.store;
1122        let serialized_request = serde_json::to_string(&request).unwrap_or_default();
1123
1124        tracing::debug!(request = %serde_json::to_string_pretty(&request).unwrap_or_default(), "Sending responses request");
1125
1126        // Make request with retry
1127        let sse_stream = match sse_request_with_retry(&cfg, || responses_request(&cfg, &request)).await {
1128            Ok(stream) => stream,
1129            Err(e) => {
1130                yield Err(e);
1131                return;
1132            }
1133        };
1134        futures_lite::pin!(sse_stream);
1135
1136        // Accumulate function calls by output_index because provider item identifiers can be
1137        // absent or empty on some streamed events.
1138        let mut function_calls: HashMap<usize, FunctionCallAccumulator> = HashMap::new();
1139        let mut usage: Option<Usage> = None;
1140        let mut usage_emitted = false;
1141
1142        while let Some(event) = sse_stream.next().await {
1143            match event {
1144                Ok(e) => {
1145                    if should_skip_event(&e) {
1146                        continue;
1147                    }
1148                    let data = e.text_data();
1149                    if data == "[DONE]" {
1150                        continue;
1151                    }
1152                    tracing::trace!(sse_event = %data, "Received Responses API SSE event");
1153
1154                    // Check for API error response
1155                    if let Ok(error_obj) = serde_json::from_str::<serde_json::Value>(data)
1156                        && let Some(error) = error_obj.get("error") {
1157                            let msg = error.get("message")
1158                                .and_then(|m| m.as_str())
1159                                .unwrap_or("Unknown API error");
1160                            if msg.contains("call_id") {
1161                                yield Err(OpenAIError::Api(format!(
1162                                    "{msg}\nSerialized responses request: {serialized_request}"
1163                                )));
1164                            } else {
1165                                yield Err(OpenAIError::Api(msg.to_string()));
1166                            }
1167                            return;
1168                        }
1169
1170                    match serde_json::from_str::<ResponsesStreamEvent>(data) {
1171                        Ok(stream_event) => {
1172                            match stream_event {
1173                                ResponsesStreamEvent::ResponseCreated { response } => {
1174                                    if let Some(meta) = response.and_then(|resp| resp.usage) {
1175                                        usage = Some(usage_from_responses(&meta));
1176                                    }
1177                                }
1178                                ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
1179                                    if !delta.is_empty() {
1180                                        yield Ok(Event::Text(delta));
1181                                    }
1182                                }
1183                                ResponsesStreamEvent::ReasoningTextDelta { delta, .. } |
1184                                ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
1185                                    if include_reasoning && !delta.is_empty() {
1186                                        yield Ok(Event::Reasoning(delta));
1187                                    }
1188                                }
1189                                ResponsesStreamEvent::OutputItemAdded { item, item_id, output_index } => {
1190                                    // When a function_call item is added, capture id and name
1191                                    if let ResponsesOutputItem::FunctionCall { id, call_id, name, .. } = item {
1192                                        let fallback_id = non_empty_response_id(item_id.clone())
1193                                            .or_else(|| non_empty_response_id(Some(id.clone())))
1194                                            .unwrap_or_else(|| fallback_response_item_id(output_index));
1195                                        let acc = function_calls.entry(output_index).or_default();
1196                                        acc.item_id = non_empty_response_id(item_id)
1197                                            .or_else(|| non_empty_response_id(Some(id)));
1198                                        acc.call_id = Some(normalize_response_call_id(call_id, &fallback_id));
1199                                        acc.name = Some(name.clone());
1200                                        // Emit initial delta with tool name
1201                                        if let Some(ref call_id) = acc.call_id {
1202                                            yield Ok(Event::ToolCallDelta {
1203                                                id: call_id.clone(),
1204                                                name,
1205                                                arguments_fragment: String::new(),
1206                                            });
1207                                        }
1208                                    }
1209                                }
1210                                ResponsesStreamEvent::FunctionCallArgumentsDelta { delta, output_index, .. } => {
1211                                    let acc = function_calls.entry(output_index).or_default();
1212                                    acc.arguments.push_str(&delta);
1213                                    // Emit the wire delta verbatim for UI progress
1214                                    if let (Some(call_id), Some(name)) = (&acc.call_id, &acc.name) {
1215                                        yield Ok(Event::ToolCallDelta {
1216                                            id: call_id.clone(),
1217                                            name: name.clone(),
1218                                            arguments_fragment: delta,
1219                                        });
1220                                    }
1221                                }
1222                                ResponsesStreamEvent::FunctionCallArgumentsDone { arguments, output_index, .. } => {
1223                                    let acc = function_calls.entry(output_index).or_default();
1224                                    acc.arguments = arguments;
1225                                }
1226                                ResponsesStreamEvent::OutputItemDone { item, item_id, output_index } => {
1227                                    // Emit function call when item is done
1228                                    if let ResponsesOutputItem::FunctionCall { id, call_id, name, arguments } = item {
1229                                        let tool_call = mark_and_emit_done_function_call(
1230                                            &mut function_calls,
1231                                            output_index,
1232                                            item_id,
1233                                            id,
1234                                            call_id,
1235                                            name,
1236                                            &arguments,
1237                                        );
1238                                        yield Ok(Event::ToolCall(tool_call));
1239                                    } else if let ResponsesOutputItem::Reasoning {
1240                                        id, encrypted_content, ..
1241                                    } = item
1242                                    {
1243                                        // Only encrypted reasoning is replayable;
1244                                        // a summary alone cannot be verified.
1245                                        if let Some(encrypted) = encrypted_content {
1246                                            let payload = serde_json::json!({
1247                                                "type": "reasoning",
1248                                                "id": id,
1249                                                "encrypted_content": encrypted,
1250                                            });
1251                                            yield Ok(Event::ReasoningState(ReasoningState::new(
1252                                                PROVIDER_NAME,
1253                                                payload.to_string(),
1254                                            )));
1255                                        }
1256                                    }
1257                                }
1258                                ResponsesStreamEvent::ResponseCompleted { response } => {
1259                                    if let Some(meta) = response.usage {
1260                                        let final_usage = usage_from_responses(&meta);
1261                                        usage = Some(final_usage.clone());
1262                                        yield Ok(Event::Usage(final_usage));
1263                                        usage_emitted = true;
1264                                    }
1265                                }
1266                                ResponsesStreamEvent::ResponseFailed { error } => {
1267                                    let msg = error
1268                                        .and_then(|e| e.message)
1269                                        .unwrap_or_else(|| "Response failed".to_string());
1270                                    if msg.contains("call_id") {
1271                                        yield Err(OpenAIError::Api(format!(
1272                                            "{msg}\nSerialized responses request: {serialized_request}"
1273                                        )));
1274                                    } else {
1275                                        yield Err(OpenAIError::Api(msg));
1276                                    }
1277                                    return;
1278                                }
1279                                ResponsesStreamEvent::Error { message, .. } => {
1280                                    let msg = message.unwrap_or_else(|| "Unknown error".to_string());
1281                                    if msg.contains("call_id") {
1282                                        yield Err(OpenAIError::Api(format!(
1283                                            "{msg}\nSerialized responses request: {serialized_request}"
1284                                        )));
1285                                    } else {
1286                                        yield Err(OpenAIError::Api(msg));
1287                                    }
1288                                    return;
1289                                }
1290                                // Ignore other events
1291                                _ => {}
1292                            }
1293                        }
1294                        Err(e) => {
1295                            tracing::warn!(error = %e, data = %data, "Failed to parse Responses API event");
1296                        }
1297                    }
1298                }
1299                Err(e) => {
1300                    yield Err(OpenAIError::from(e));
1301                    return;
1302                }
1303            }
1304        }
1305
1306        // Emit any remaining accumulated function calls (fallback if OutputItemDone wasn't received)
1307        for tool_call in drain_pending_function_calls(function_calls) {
1308            yield Ok(Event::ToolCall(tool_call));
1309        }
1310        if !usage_emitted && let Some(final_usage) = usage {
1311            yield Ok(Event::Usage(final_usage));
1312        }
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319    use crate::response::{
1320        ChatCompletionTokensDetails, ChatCompletionUsage, ChatPromptTokensDetails,
1321        DeltaToolFunction, ResponsesInputTokenDetails, ResponsesOutputTokenDetails, ResponsesUsage,
1322    };
1323    use std::time::Duration;
1324
1325    fn chat_delta(id: Option<&str>, name: Option<&str>, arguments: &str) -> DeltaToolCall {
1326        DeltaToolCall {
1327            index: Some(0),
1328            id: id.map(str::to_string),
1329            function: Some(DeltaToolFunction {
1330                name: name.map(str::to_string),
1331                arguments: Some(arguments.to_string()),
1332            }),
1333        }
1334    }
1335
1336    #[test]
1337    fn chat_tool_call_accumulates_name_and_arguments() {
1338        let mut tool_calls = HashMap::new();
1339        accumulate_tool_call_delta(
1340            &mut tool_calls,
1341            &chat_delta(Some("call_1"), Some("search"), ""),
1342        );
1343        accumulate_tool_call_delta(&mut tool_calls, &chat_delta(None, None, "{\"q\":"));
1344        accumulate_tool_call_delta(&mut tool_calls, &chat_delta(None, None, "\"rust\"}"));
1345
1346        let acc = &tool_calls[&0];
1347        assert_eq!(acc.id.as_deref(), Some("call_1"));
1348        assert_eq!(acc.name.as_deref(), Some("search"));
1349        assert_eq!(acc.arguments, "{\"q\":\"rust\"}");
1350    }
1351
1352    #[test]
1353    fn chat_tool_call_empty_delta_fields_do_not_overwrite() {
1354        let mut tool_calls = HashMap::new();
1355        accumulate_tool_call_delta(
1356            &mut tool_calls,
1357            &chat_delta(Some("call_1"), Some("search"), ""),
1358        );
1359        // Some compatible servers repeat `id`/`name` as empty strings on
1360        // every continuation delta instead of omitting them.
1361        accumulate_tool_call_delta(&mut tool_calls, &chat_delta(Some(""), Some(""), "{\"q\":"));
1362        accumulate_tool_call_delta(
1363            &mut tool_calls,
1364            &chat_delta(Some(""), Some(""), "\"rust\"}"),
1365        );
1366
1367        let acc = &tool_calls[&0];
1368        assert_eq!(acc.id.as_deref(), Some("call_1"));
1369        assert_eq!(acc.name.as_deref(), Some("search"));
1370        assert_eq!(acc.arguments, "{\"q\":\"rust\"}");
1371    }
1372
1373    #[test]
1374    fn done_function_call_is_not_emitted_twice() {
1375        let mut function_calls = HashMap::new();
1376        let emitted = mark_and_emit_done_function_call(
1377            &mut function_calls,
1378            1,
1379            Some("item_1".to_string()),
1380            "item_1".to_string(),
1381            Some("call_1".to_string()),
1382            "search".to_string(),
1383            r#"{"q":"rust"}"#,
1384        );
1385        assert_eq!(emitted.id, "call_1");
1386        assert_eq!(emitted.name, "search");
1387        assert_eq!(drain_pending_function_calls(function_calls).len(), 0);
1388    }
1389
1390    #[test]
1391    fn pending_function_call_is_emitted_once_via_fallback() {
1392        let mut function_calls = HashMap::new();
1393        function_calls.insert(
1394            2,
1395            FunctionCallAccumulator {
1396                item_id: Some("item_2".to_string()),
1397                call_id: Some("call_2".to_string()),
1398                name: Some("lookup".to_string()),
1399                arguments: r#"{"id":42}"#.to_string(),
1400                emitted: false,
1401            },
1402        );
1403        let pending = drain_pending_function_calls(function_calls);
1404        assert_eq!(pending.len(), 1);
1405        assert_eq!(pending[0].id, "call_2");
1406        assert_eq!(pending[0].name, "lookup");
1407    }
1408
1409    #[test]
1410    fn empty_done_call_id_falls_back_to_item_id() {
1411        let mut function_calls = HashMap::new();
1412        let emitted = mark_and_emit_done_function_call(
1413            &mut function_calls,
1414            3,
1415            Some("item_3".to_string()),
1416            "item_3".to_string(),
1417            Some(String::new()),
1418            "search".to_string(),
1419            r#"{"q":"terminal first"}"#,
1420        );
1421        assert_eq!(emitted.id, "item_3");
1422    }
1423
1424    #[test]
1425    fn empty_pending_call_id_falls_back_to_item_id() {
1426        let mut function_calls = HashMap::new();
1427        function_calls.insert(
1428            4,
1429            FunctionCallAccumulator {
1430                item_id: Some("item_4".to_string()),
1431                call_id: Some("   ".to_string()),
1432                name: Some("lookup".to_string()),
1433                arguments: r#"{"id":7}"#.to_string(),
1434                emitted: false,
1435            },
1436        );
1437        let pending = drain_pending_function_calls(function_calls);
1438        assert_eq!(pending.len(), 1);
1439        assert_eq!(pending[0].id, "item_4");
1440        assert_eq!(pending[0].name, "lookup");
1441    }
1442
1443    #[test]
1444    fn empty_stream_identifiers_fall_back_to_output_index() {
1445        let mut function_calls = HashMap::new();
1446        function_calls.insert(
1447            12,
1448            FunctionCallAccumulator {
1449                item_id: None,
1450                call_id: None,
1451                name: None,
1452                arguments: r#"{"q":"may"}"#.to_string(),
1453                emitted: false,
1454            },
1455        );
1456        let emitted = mark_and_emit_done_function_call(
1457            &mut function_calls,
1458            12,
1459            None,
1460            String::new(),
1461            Some(String::new()),
1462            "search".to_string(),
1463            r#"{"q":"may"}"#,
1464        );
1465        assert_eq!(emitted.id, "response_output_12");
1466    }
1467
1468    #[test]
1469    fn serialized_responses_request_rejects_empty_call_id() {
1470        let snapshot = ParameterSnapshot::from(&aither_core::llm::model::Parameters::default());
1471        let request = ResponsesRequest::new(
1472            "gpt-5.4".to_string(),
1473            vec![ResponsesInputItem::FunctionCall {
1474                kind: "function_call",
1475                call_id: String::new(),
1476                name: "search".to_string(),
1477                arguments: "{}".to_string(),
1478            }],
1479            &snapshot,
1480            None,
1481            responses_tool_choice(&snapshot, false),
1482            false,
1483        );
1484
1485        let error = validate_serialized_responses_request(&request)
1486            .expect_err("empty serialized call_id must fail");
1487        assert!(
1488            error.to_string().contains("input[0]"),
1489            "unexpected error: {error}"
1490        );
1491    }
1492
1493    #[test]
1494    fn serialized_responses_request_rejects_whitespace_call_id() {
1495        let snapshot = ParameterSnapshot::from(&aither_core::llm::model::Parameters::default());
1496        let request = ResponsesRequest::new(
1497            "gpt-5.4".to_string(),
1498            vec![ResponsesInputItem::FunctionCallOutput {
1499                kind: "function_call_output",
1500                call_id: "   ".to_string(),
1501                output: "{}".to_string(),
1502            }],
1503            &snapshot,
1504            None,
1505            responses_tool_choice(&snapshot, false),
1506            false,
1507        );
1508
1509        let error = validate_serialized_responses_request(&request)
1510            .expect_err("whitespace serialized call_id must fail");
1511        assert!(
1512            error.to_string().contains("blank call_id"),
1513            "unexpected error: {error}"
1514        );
1515    }
1516
1517    #[tokio::test]
1518    async fn configured_timeout_is_enforced_for_sse_requests() {
1519        let timeout = request_with_timeout(
1520            Duration::from_millis(20),
1521            futures_lite::future::pending::<Result<(), zenwave::Error>>(),
1522        )
1523        .await;
1524        assert!(matches!(timeout, Err(OpenAIError::Timeout)));
1525    }
1526
1527    #[test]
1528    fn chat_usage_mapping_keeps_reasoning_and_cache_tokens() {
1529        let usage = ChatCompletionUsage {
1530            prompt_tokens: Some(11),
1531            completion_tokens: Some(7),
1532            total_tokens: Some(18),
1533            prompt_tokens_details: Some(ChatPromptTokensDetails {
1534                cached_tokens: Some(3),
1535            }),
1536            completion_tokens_details: Some(ChatCompletionTokensDetails {
1537                reasoning_tokens: Some(2),
1538            }),
1539        };
1540        let mapped = usage_from_chat_completion(&usage);
1541        assert_eq!(mapped.prompt_tokens, Some(11));
1542        assert_eq!(mapped.completion_tokens, Some(7));
1543        assert_eq!(mapped.total_tokens, Some(18));
1544        assert_eq!(mapped.reasoning_tokens, Some(2));
1545        assert_eq!(mapped.cache_read_tokens, Some(3));
1546    }
1547
1548    #[test]
1549    fn responses_usage_mapping_keeps_reasoning_and_cache_tokens() {
1550        let usage = ResponsesUsage {
1551            input_tokens: Some(19),
1552            output_tokens: Some(5),
1553            total_tokens: Some(24),
1554            input_token_details: Some(ResponsesInputTokenDetails {
1555                cached_tokens: Some(4),
1556            }),
1557            output_token_details: Some(ResponsesOutputTokenDetails {
1558                reasoning_tokens: Some(1),
1559            }),
1560        };
1561        let mapped = usage_from_responses(&usage);
1562        assert_eq!(mapped.prompt_tokens, Some(19));
1563        assert_eq!(mapped.completion_tokens, Some(5));
1564        assert_eq!(mapped.total_tokens, Some(24));
1565        assert_eq!(mapped.reasoning_tokens, Some(1));
1566        assert_eq!(mapped.cache_read_tokens, Some(4));
1567    }
1568}
1569
1570fn filter_tool_definitions(
1571    defs: Vec<aither_core::llm::tool::ToolDefinition>,
1572    choice: &ToolChoice,
1573) -> Vec<aither_core::llm::tool::ToolDefinition> {
1574    match choice {
1575        ToolChoice::None => Vec::new(),
1576        ToolChoice::Exact(name) => defs
1577            .into_iter()
1578            .filter(|tool| tool.name() == name)
1579            .collect(),
1580        ToolChoice::Auto | ToolChoice::Required => defs,
1581    }
1582}
1583
1584/// Builder for [`OpenAI`] clients.
1585#[derive(Debug)]
1586pub struct Builder {
1587    api_key: String,
1588    base_url: String,
1589    api_kind: ApiKind,
1590    chat_model: String,
1591    embedding_model: String,
1592    embedding_dimensions: usize,
1593    image_model: String,
1594    audio_model: String,
1595    audio_voice: String,
1596    audio_format: String,
1597    transcription_model: String,
1598    moderation_model: String,
1599    legacy_max_tokens: bool,
1600    organization: Option<String>,
1601    native_abilities: Vec<Ability>,
1602    retry: RetryConfig,
1603    request_timeout: Duration,
1604    extra_headers: Vec<(String, String)>,
1605    store: Option<bool>,
1606}
1607
1608/// Default request timeout (5 minutes - generous for long completions).
1609const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
1610
1611impl Builder {
1612    fn new(api_key: impl Into<String>) -> Self {
1613        Self {
1614            api_key: api_key.into(),
1615            base_url: DEFAULT_BASE_URL.to_string(),
1616            api_kind: ApiKind::Responses,
1617            chat_model: DEFAULT_MODEL.to_string(),
1618            embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(),
1619            embedding_dimensions: DEFAULT_EMBEDDING_DIM,
1620            image_model: DEFAULT_IMAGE_MODEL.to_string(),
1621            audio_model: DEFAULT_AUDIO_MODEL.to_string(),
1622            audio_voice: DEFAULT_AUDIO_VOICE.to_string(),
1623            audio_format: DEFAULT_AUDIO_FORMAT.to_string(),
1624            transcription_model: DEFAULT_TRANSCRIPTION_MODEL.to_string(),
1625            moderation_model: DEFAULT_MODERATION_MODEL.to_string(),
1626            legacy_max_tokens: false,
1627            organization: None,
1628            native_abilities: Vec::new(),
1629            retry: RetryConfig::default(),
1630            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1631            extra_headers: Vec::new(),
1632            store: None,
1633        }
1634    }
1635
1636    /// Set a custom API base URL.
1637    #[must_use]
1638    pub fn base_url(mut self, url: impl Into<String>) -> Self {
1639        self.base_url = url.into();
1640        self
1641    }
1642
1643    /// Use the recommended Responses API.
1644    #[must_use]
1645    pub const fn use_responses_api(mut self) -> Self {
1646        self.api_kind = ApiKind::Responses;
1647        self
1648    }
1649
1650    /// Use the legacy Chat Completions API (deprecated by `OpenAI`).
1651    #[must_use]
1652    pub const fn use_chat_completions_api(mut self) -> Self {
1653        self.api_kind = ApiKind::ChatCompletions;
1654        self
1655    }
1656
1657    /// Send deprecated `max_tokens` alongside `max_completion_tokens` for compatibility.
1658    ///
1659    /// `OpenAI` deprecates `max_tokens` and it is incompatible with reasoning models.
1660    #[must_use]
1661    pub const fn legacy_max_tokens(mut self, enabled: bool) -> Self {
1662        self.legacy_max_tokens = enabled;
1663        self
1664    }
1665
1666    /// Select a model identifier (e.g., `gpt-4o-mini`, `o1-mini`).
1667    #[must_use]
1668    pub fn model(mut self, model: impl Into<String>) -> Self {
1669        self.chat_model = sanitize_model(model);
1670        self
1671    }
1672
1673    /// Select the embeddings model identifier.
1674    #[must_use]
1675    pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
1676        let model = sanitize_model(model);
1677        if let Some(dim) = infer_embedding_dim(&model) {
1678            self.embedding_dimensions = dim;
1679        }
1680        self.embedding_model = model;
1681        self
1682    }
1683
1684    /// Override the embedding vector dimension.
1685    #[must_use]
1686    pub const fn embedding_dimensions(mut self, dimensions: usize) -> Self {
1687        self.embedding_dimensions = dimensions;
1688        self
1689    }
1690
1691    /// Select the image model identifier.
1692    #[must_use]
1693    pub fn image_model(mut self, model: impl Into<String>) -> Self {
1694        self.image_model = sanitize_model(model);
1695        self
1696    }
1697
1698    /// Select the default audio generation model.
1699    #[must_use]
1700    pub fn audio_model(mut self, model: impl Into<String>) -> Self {
1701        self.audio_model = sanitize_model(model);
1702        self
1703    }
1704
1705    /// Override the default TTS voice.
1706    #[must_use]
1707    pub fn audio_voice(mut self, voice: impl Into<String>) -> Self {
1708        self.audio_voice = voice.into();
1709        self
1710    }
1711
1712    /// Override the synthesized audio format.
1713    #[must_use]
1714    pub fn audio_format(mut self, format: impl Into<String>) -> Self {
1715        self.audio_format = format.into();
1716        self
1717    }
1718
1719    /// Select the transcription model identifier.
1720    #[must_use]
1721    pub fn transcription_model(mut self, model: impl Into<String>) -> Self {
1722        self.transcription_model = sanitize_model(model);
1723        self
1724    }
1725
1726    /// Select the moderation model identifier.
1727    #[must_use]
1728    pub fn moderation_model(mut self, model: impl Into<String>) -> Self {
1729        self.moderation_model = sanitize_model(model);
1730        self
1731    }
1732
1733    /// Attach an `OpenAI` organization header.
1734    #[must_use]
1735    pub fn organization(mut self, organization: impl Into<String>) -> Self {
1736        self.organization = Some(organization.into());
1737        self
1738    }
1739
1740    /// Attach an extra header sent with every request.
1741    ///
1742    /// Used by endpoints that authenticate or route on custom headers (for
1743    /// example the `ChatGPT` Codex backend's `ChatGPT-Account-Id`). Headers set
1744    /// here are sent alongside the built-in `Authorization` header.
1745    #[must_use]
1746    pub fn extra_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1747        self.extra_headers.push((name.into(), value.into()));
1748        self
1749    }
1750
1751    /// Set the Responses API `store` field.
1752    ///
1753    /// `store: false` disables server-side response persistence. Some
1754    /// backends (the `ChatGPT` Codex endpoint) require it to be present and
1755    /// false; the default `None` omits the field entirely.
1756    #[must_use]
1757    pub const fn store(mut self, store: bool) -> Self {
1758        self.store = Some(store);
1759        self
1760    }
1761
1762    /// Declare extra native capabilities (e.g., web search, PDF understanding) supported by the upstream model.
1763    #[must_use]
1764    pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
1765        for ability in abilities {
1766            if !self.native_abilities.contains(&ability) {
1767                self.native_abilities.push(ability);
1768            }
1769        }
1770        self
1771    }
1772
1773    /// Mark this model as having built-in web search support.
1774    #[must_use]
1775    pub fn enable_native_web_search(self) -> Self {
1776        self.native_capabilities([Ability::WebSearch])
1777    }
1778
1779    /// Mark this model as having native PDF/document understanding.
1780    #[must_use]
1781    pub fn enable_native_pdf(self) -> Self {
1782        self.native_capabilities([Ability::Pdf])
1783    }
1784
1785    /// Configure retry behavior for failed requests.
1786    ///
1787    /// By default, requests are retried up to 3 times with exponential backoff.
1788    /// Retries happen on network errors and certain HTTP status codes (429, 500, 502, 503, 504).
1789    #[must_use]
1790    pub const fn retry(mut self, config: RetryConfig) -> Self {
1791        self.retry = config;
1792        self
1793    }
1794
1795    /// Set maximum number of retry attempts.
1796    #[must_use]
1797    pub const fn max_retries(mut self, max_retries: u32) -> Self {
1798        self.retry.max_retries = max_retries;
1799        self
1800    }
1801
1802    /// Disable retries entirely.
1803    #[must_use]
1804    pub fn no_retry(mut self) -> Self {
1805        self.retry = RetryConfig::none();
1806        self
1807    }
1808
1809    /// Set the request timeout.
1810    ///
1811    /// Default is 5 minutes, which is generous for long completions.
1812    /// The timeout applies to the entire request, including connection and response streaming.
1813    #[must_use]
1814    pub const fn timeout(mut self, timeout: Duration) -> Self {
1815        self.request_timeout = timeout;
1816        self
1817    }
1818
1819    /// Consume the builder and create an [`OpenAI`] client.
1820    #[must_use]
1821    pub fn build(self) -> OpenAI {
1822        OpenAI {
1823            inner: Arc::new(Config {
1824                api_key: self.api_key,
1825                base_url: self.base_url,
1826                api_kind: self.api_kind,
1827                chat_model: self.chat_model,
1828                embedding_model: self.embedding_model,
1829                embedding_dimensions: self.embedding_dimensions,
1830                image_model: self.image_model,
1831                audio_model: self.audio_model,
1832                audio_voice: self.audio_voice,
1833                audio_format: self.audio_format,
1834                transcription_model: self.transcription_model,
1835                moderation_model: self.moderation_model,
1836                legacy_max_tokens: self.legacy_max_tokens,
1837                organization: self.organization,
1838                native_abilities: self.native_abilities,
1839                retry: self.retry,
1840                request_timeout: self.request_timeout,
1841                extra_headers: self.extra_headers,
1842                store: self.store,
1843            }),
1844        }
1845    }
1846}
1847
1848#[derive(Debug, Clone)]
1849pub struct Config {
1850    pub(crate) api_key: String,
1851    pub(crate) base_url: String,
1852    pub(crate) api_kind: ApiKind,
1853    pub(crate) chat_model: String,
1854    pub(crate) embedding_model: String,
1855    pub(crate) embedding_dimensions: usize,
1856    pub(crate) image_model: String,
1857    pub(crate) audio_model: String,
1858    pub(crate) audio_voice: String,
1859    pub(crate) audio_format: String,
1860    pub(crate) transcription_model: String,
1861    pub(crate) moderation_model: String,
1862    pub(crate) legacy_max_tokens: bool,
1863    pub(crate) organization: Option<String>,
1864    pub(crate) native_abilities: Vec<Ability>,
1865    pub(crate) retry: RetryConfig,
1866    pub(crate) request_timeout: Duration,
1867    pub(crate) extra_headers: Vec<(String, String)>,
1868    pub(crate) store: Option<bool>,
1869}
1870
1871impl Config {
1872    pub(crate) fn request_url(&self, path: &str) -> String {
1873        format!(
1874            "{}/{}",
1875            self.base_url.trim_end_matches('/'),
1876            path.trim_start_matches('/')
1877        )
1878    }
1879
1880    pub(crate) fn request_auth(&self) -> String {
1881        format!("Bearer {}", self.api_key)
1882    }
1883}
1884
1885fn sanitize_model(model: impl Into<String>) -> String {
1886    model.into().trim().to_string()
1887}
1888
1889fn infer_embedding_dim(model: &str) -> Option<usize> {
1890    match model {
1891        "text-embedding-3-large" => Some(3072),
1892        "text-embedding-3-small" | "text-embedding-ada-002" => Some(1536),
1893        _ => None,
1894    }
1895}