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, ResponsesOutputItem, ResponsesStreamEvent,
14        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 response: ModelsListResponse = request_with_timeout(
557        cfg.request_timeout,
558        backend
559            .get(&url)
560            .map_err(OpenAIError::Http)?
561            .header(
562                header::AUTHORIZATION.as_str(),
563                format!("Bearer {}", cfg.api_key),
564            )
565            .map_err(OpenAIError::Http)?
566            .json(),
567    )
568    .await?;
569
570    // Find matching model
571    let mut model_found = false;
572    for model in response.data {
573        if model.id == cfg.chat_model {
574            model_found = true;
575            if let Some(ctx) = model.context_length.or(model.max_tokens) {
576                tracing::debug!(
577                    "Fetched context_length={} for model '{}'",
578                    ctx,
579                    cfg.chat_model
580                );
581                return Ok(ctx);
582            }
583        }
584    }
585
586    // More specific error message
587    if model_found {
588        Err(OpenAIError::Api(format!(
589            "Model '{}' found but missing context_length field (proxy may not support it)",
590            cfg.chat_model
591        )))
592    } else {
593        Err(OpenAIError::Api(format!(
594            "Model '{}' not found in /models response",
595            cfg.chat_model
596        )))
597    }
598}
599
600fn chat_completions_stream(
601    cfg: Arc<Config>,
602    payload_messages: Vec<ChatMessagePayload>,
603    snapshot: ParameterSnapshot,
604    openai_tools: Option<Vec<ToolPayload>>,
605) -> impl Stream<Item = Result<Event, OpenAIError>> + Send + Unpin {
606    Box::pin(chat_completions_stream_inner(
607        cfg,
608        payload_messages,
609        snapshot,
610        openai_tools,
611    ))
612}
613
614/// Accumulated tool call state for streaming.
615#[derive(Debug, Default)]
616struct ToolCallAccumulator {
617    id: Option<String>,
618    name: Option<String>,
619    arguments: String,
620}
621
622/// Make a chat completions SSE request (single attempt).
623async fn chat_completions_request(
624    cfg: &Config,
625    request: &ChatCompletionRequest,
626) -> SseStreamResult {
627    let endpoint = cfg.request_url("/chat/completions");
628    let mut backend = client();
629
630    let build_result = backend
631        .post(endpoint)
632        .and_then(|b| b.header(header::AUTHORIZATION.as_str(), cfg.request_auth()))
633        .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-openai/0.1"))
634        .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"));
635
636    let mut builder = build_result.map_err(OpenAIError::Http)?;
637
638    if let Some(org) = &cfg.organization {
639        builder = builder
640            .header("OpenAI-Organization", org.clone())
641            .map_err(OpenAIError::Http)?;
642    }
643
644    request_with_timeout(
645        cfg.request_timeout,
646        builder.json_body(request).map_err(OpenAIError::Http)?.sse(),
647    )
648    .await
649}
650
651#[allow(clippy::too_many_lines)]
652fn chat_completions_stream_inner(
653    cfg: Arc<Config>,
654    payload_messages: Vec<ChatMessagePayload>,
655    snapshot: ParameterSnapshot,
656    openai_tools: Option<Vec<ToolPayload>>,
657) -> impl Stream<Item = Result<Event, OpenAIError>> + Send {
658    let include_reasoning = snapshot.include_reasoning;
659
660    async_stream::stream! {
661        let request = ChatCompletionRequest::new(
662            cfg.chat_model.clone(),
663            payload_messages,
664            &snapshot,
665            openai_tools,
666            true,
667        );
668
669        tracing::debug!(request = %serde_json::to_string_pretty(&request).unwrap_or_default(), "Sending chat completion request");
670
671        // Make request with retry
672        let sse_stream = match sse_request_with_retry(&cfg, || chat_completions_request(&cfg, &request)).await {
673            Ok(stream) => stream,
674            Err(e) => {
675                yield Err(e);
676                return;
677            }
678        };
679        futures_lite::pin!(sse_stream);
680
681        // Accumulate tool calls by index - streaming sends id/name first, then arguments incrementally
682        let mut tool_calls: std::collections::HashMap<usize, ToolCallAccumulator> =
683            std::collections::HashMap::new();
684        let mut usage: Option<Usage> = None;
685        while let Some(event) = sse_stream.next().await {
686            match event {
687                Ok(e) => {
688                    if should_skip_event(&e) {
689                        continue;
690                    }
691                    let data = e.text_data();
692                    if data == "[DONE]" {
693                        continue;
694                    }
695                    tracing::debug!(sse_event = %data, "Received SSE event");
696
697                    // Check for API error response
698                    if let Ok(error_obj) = serde_json::from_str::<serde_json::Value>(data)
699                        && let Some(error) = error_obj.get("error") {
700                            let msg = error.get("message")
701                                .and_then(|m| m.as_str())
702                                .unwrap_or("Unknown API error");
703                            yield Err(OpenAIError::Api(msg.to_string()));
704                            return;
705                        }
706
707                    match serde_json::from_str::<ChatCompletionChunk>(data) {
708                        Ok(chunk) => {
709                            if let Some(chunk_usage) = &chunk.usage {
710                                usage = Some(usage_from_chat_completion(chunk_usage));
711                            }
712                            // Emit text events
713                            for choice in &chunk.choices {
714                                // Check for malformed function call
715                                if let Some(ref reason) = choice.finish_reason
716                                    && reason.contains("malformed_function_call") {
717                                        yield Err(OpenAIError::Api("malformed function call".to_string()));
718                                        return;
719                                    }
720
721                                if let Some(content) = &choice.delta.content
722                                    && !content.is_empty() {
723                                        yield Ok(Event::Text(content.clone()));
724                                    }
725                                // Emit reasoning if enabled
726                                if include_reasoning
727                                    && let Some(reasoning) = &choice.delta.reasoning_content
728                                        && !reasoning.is_empty() {
729                                            yield Ok(Event::Reasoning(reasoning.clone()));
730                                        }
731                                // Accumulate tool calls and emit deltas
732                                if let Some(calls) = &choice.delta.tool_calls {
733                                    for call in calls {
734                                        let index = call.index.unwrap_or(0);
735                                        let acc = tool_calls.entry(index).or_default();
736                                        if let Some(id) = &call.id {
737                                            acc.id = Some(id.clone());
738                                        }
739                                        if let Some(function) = &call.function {
740                                            if let Some(name) = &function.name {
741                                                acc.name = Some(name.clone());
742                                            }
743                                            if let Some(args) = &function.arguments {
744                                                acc.arguments.push_str(args);
745                                            }
746                                        }
747                                        // Emit delta for UI progress
748                                        if let (Some(id), Some(name)) = (&acc.id, &acc.name) {
749                                            yield Ok(Event::ToolCallDelta {
750                                                id: id.clone(),
751                                                name: name.clone(),
752                                                arguments_fragment: acc.arguments.clone(),
753                                            });
754                                        }
755                                    }
756                                }
757                            }
758                        }
759                        Err(e) => {
760                            yield Err(OpenAIError::from(e));
761                            return;
762                        }
763                    }
764                }
765                Err(e) => {
766                    yield Err(OpenAIError::from(e));
767                    return;
768                }
769            }
770        }
771
772        // Emit accumulated tool calls at the end
773        let mut sorted_calls: Vec<_> = tool_calls.into_iter().collect();
774        sorted_calls.sort_by_key(|(index, _)| *index);
775        for (_, acc) in sorted_calls {
776            if let (Some(id), Some(name)) = (acc.id, acc.name) {
777                let arguments = if acc.arguments.is_empty() {
778                    serde_json::Value::Object(serde_json::Map::default())
779                } else {
780                    serde_json::from_str(&acc.arguments).unwrap_or_else(|_| {
781                        serde_json::Value::Object(serde_json::Map::default())
782                    })
783                };
784                yield Ok(Event::ToolCall(ToolCall {
785                    id,
786                    name,
787                    arguments,
788                    reasoning_state: None,
789                }));
790            }
791        }
792        if let Some(final_usage) = usage {
793            yield Ok(Event::Usage(final_usage));
794        }
795    }
796}
797
798fn responses_stream(
799    cfg: Arc<Config>,
800    input: Vec<ResponsesInputItem>,
801    snapshot: ParameterSnapshot,
802    tool_defs: Vec<aither_core::llm::tool::ToolDefinition>,
803) -> impl Stream<Item = Result<Event, OpenAIError>> + Send + Unpin {
804    Box::pin(responses_stream_inner(cfg, input, snapshot, tool_defs))
805}
806
807/// Accumulated function call state for Responses API streaming.
808#[derive(Debug, Default)]
809struct FunctionCallAccumulator {
810    item_id: Option<String>,
811    call_id: Option<String>,
812    name: Option<String>,
813    arguments: String,
814    emitted: bool,
815}
816
817fn normalize_response_call_id(call_id: Option<String>, fallback: &str) -> String {
818    match call_id {
819        Some(call_id) if !call_id.trim().is_empty() => call_id,
820        _ => fallback.to_string(),
821    }
822}
823
824fn non_empty_response_id(value: Option<String>) -> Option<String> {
825    value.filter(|value| !value.trim().is_empty())
826}
827
828fn fallback_response_item_id(output_index: usize) -> String {
829    format!("response_output_{output_index}")
830}
831
832fn parse_tool_call_arguments(arguments: &str) -> serde_json::Value {
833    if arguments.is_empty() {
834        serde_json::Value::Object(serde_json::Map::default())
835    } else {
836        serde_json::from_str(arguments)
837            .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::default()))
838    }
839}
840
841fn usage_from_chat_completion(raw: &ChatCompletionUsage) -> Usage {
842    Usage {
843        prompt_tokens: raw.prompt_tokens,
844        completion_tokens: raw.completion_tokens,
845        total_tokens: raw.total_tokens,
846        reasoning_tokens: raw
847            .completion_tokens_details
848            .as_ref()
849            .and_then(|details| details.reasoning_tokens),
850        cache_read_tokens: raw
851            .prompt_tokens_details
852            .as_ref()
853            .and_then(|details| details.cached_tokens),
854        cache_write_tokens: None,
855        cost_usd: None,
856        stop_reason: None,
857    }
858}
859
860fn usage_from_responses(raw: &ResponsesUsage) -> Usage {
861    Usage {
862        prompt_tokens: raw.input_tokens,
863        completion_tokens: raw.output_tokens,
864        total_tokens: raw.total_tokens,
865        reasoning_tokens: raw
866            .output_token_details
867            .as_ref()
868            .and_then(|details| details.reasoning_tokens),
869        cache_read_tokens: raw
870            .input_token_details
871            .as_ref()
872            .and_then(|details| details.cached_tokens),
873        cache_write_tokens: None,
874        cost_usd: None,
875        stop_reason: None,
876    }
877}
878
879fn mark_and_emit_done_function_call(
880    function_calls: &mut HashMap<usize, FunctionCallAccumulator>,
881    output_index: usize,
882    item_id: Option<String>,
883    response_id: String,
884    call_id: Option<String>,
885    name: String,
886    arguments: &str,
887) -> ToolCall {
888    let fallback_id = non_empty_response_id(item_id.clone())
889        .or_else(|| non_empty_response_id(Some(response_id.clone())))
890        .unwrap_or_else(|| fallback_response_item_id(output_index));
891    let resolved_call_id = normalize_response_call_id(call_id, &fallback_id);
892
893    let acc = function_calls.entry(output_index).or_default();
894    acc.item_id =
895        non_empty_response_id(item_id).or_else(|| non_empty_response_id(Some(response_id)));
896    acc.call_id = Some(resolved_call_id.clone());
897    acc.name = Some(name.clone());
898    acc.arguments.clear();
899    acc.arguments.push_str(arguments);
900    acc.emitted = true;
901
902    ToolCall {
903        id: resolved_call_id,
904        name,
905        arguments: parse_tool_call_arguments(arguments),
906        reasoning_state: None,
907    }
908}
909
910fn drain_pending_function_calls(
911    function_calls: HashMap<usize, FunctionCallAccumulator>,
912) -> Vec<ToolCall> {
913    function_calls
914        .into_iter()
915        .filter(|(_, acc)| !acc.emitted)
916        .filter_map(|(output_index, acc)| {
917            let name = acc.name?;
918            let fallback_id = acc
919                .item_id
920                .clone()
921                .unwrap_or_else(|| fallback_response_item_id(output_index));
922            let call_id = normalize_response_call_id(acc.call_id, &fallback_id);
923            Some(ToolCall {
924                id: call_id,
925                name,
926                arguments: parse_tool_call_arguments(&acc.arguments),
927                reasoning_state: None,
928            })
929        })
930        .collect()
931}
932
933#[allow(clippy::items_after_statements)]
934fn validate_serialized_responses_request(request: &ResponsesRequest) -> Result<(), OpenAIError> {
935    let request_value = serde_json::to_value(request).map_err(OpenAIError::Json)?;
936    let Some(items) = request_value
937        .get("input")
938        .and_then(serde_json::Value::as_array)
939    else {
940        return Ok(());
941    };
942
943    fn find_blank_call_id(value: &serde_json::Value, path: &str) -> Option<(String, String)> {
944        match value {
945            serde_json::Value::Object(map) => {
946                for (key, child) in map {
947                    let child_path = format!("{path}.{key}");
948                    if key == "call_id"
949                        && let Some(call_id) = child.as_str()
950                        && call_id.trim().is_empty()
951                    {
952                        return Some((child_path, call_id.to_string()));
953                    }
954                    if let Some(found) = find_blank_call_id(child, &child_path) {
955                        return Some(found);
956                    }
957                }
958                None
959            }
960            serde_json::Value::Array(items) => {
961                for (index, child) in items.iter().enumerate() {
962                    let child_path = format!("{path}[{index}]");
963                    if let Some(found) = find_blank_call_id(child, &child_path) {
964                        return Some(found);
965                    }
966                }
967                None
968            }
969            serde_json::Value::Null
970            | serde_json::Value::Bool(_)
971            | serde_json::Value::Number(_)
972            | serde_json::Value::String(_) => None,
973        }
974    }
975
976    for (index, item) in items.iter().enumerate() {
977        if let Some((path, call_id)) = find_blank_call_id(item, &format!("input[{index}]")) {
978            let snippet = serde_json::to_string(item).map_err(OpenAIError::Json)?;
979            return Err(OpenAIError::Api(format!(
980                "responses request serialized blank call_id at {path}: {call_id:?}; item={snippet}"
981            )));
982        }
983    }
984    Ok(())
985}
986
987/// Make a responses API SSE request (single attempt).
988async fn responses_request(cfg: &Config, request: &ResponsesRequest) -> SseStreamResult {
989    validate_serialized_responses_request(request)?;
990    let serialized_request = serde_json::to_string(request).map_err(OpenAIError::Json)?;
991    let endpoint = cfg.request_url("/responses");
992    let mut backend = client();
993
994    let build_result = backend
995        .post(endpoint)
996        .and_then(|b| b.header(header::AUTHORIZATION.as_str(), cfg.request_auth()))
997        .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-openai/0.1"))
998        .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"));
999
1000    let mut builder = build_result.map_err(OpenAIError::Http)?;
1001
1002    if let Some(org) = &cfg.organization {
1003        builder = builder
1004            .header("OpenAI-Organization", org.clone())
1005            .map_err(OpenAIError::Http)?;
1006    }
1007
1008    match request_with_timeout(
1009        cfg.request_timeout,
1010        builder.json_body(request).map_err(OpenAIError::Http)?.sse(),
1011    )
1012    .await
1013    {
1014        Ok(stream) => Ok(stream),
1015        Err(error) => {
1016            if error.to_string().contains("call_id") {
1017                return Err(OpenAIError::Api(format!(
1018                    "{error}\nSerialized responses request: {serialized_request}"
1019                )));
1020            }
1021            Err(error)
1022        }
1023    }
1024}
1025
1026#[allow(clippy::too_many_lines, clippy::collapsible_match)]
1027fn responses_stream_inner(
1028    cfg: Arc<Config>,
1029    input: Vec<ResponsesInputItem>,
1030    snapshot: ParameterSnapshot,
1031    tool_defs: Vec<aither_core::llm::tool::ToolDefinition>,
1032) -> impl Stream<Item = Result<Event, OpenAIError>> + Send {
1033    let include_reasoning = snapshot.include_reasoning;
1034
1035    async_stream::stream! {
1036        let mut response_tools = convert_responses_tools(tool_defs);
1037        let allow_builtins = matches!(snapshot.tool_choice, ToolChoice::Auto | ToolChoice::Required);
1038        if allow_builtins {
1039            let native_tools = snapshot.openai_tools.clone();
1040            if snapshot.websearch || native_tools.web_search.is_some() {
1041                let tool = native_tools.web_search.unwrap_or_default();
1042                response_tools.push(ResponsesTool::from(tool));
1043            }
1044            for tool in native_tools.file_search {
1045                response_tools.push(ResponsesTool::from(tool));
1046            }
1047            if snapshot.code_execution || native_tools.code_interpreter.is_some() {
1048                let tool = native_tools.code_interpreter.unwrap_or_default();
1049                response_tools.push(ResponsesTool::from(tool));
1050            }
1051            if let Some(tool) = native_tools.image_generation {
1052                response_tools.push(ResponsesTool::from(tool));
1053            }
1054            for tool in native_tools.mcp {
1055                response_tools.push(ResponsesTool::from(tool));
1056            }
1057            if let Some(tool) = native_tools.computer_use {
1058                response_tools.push(ResponsesTool::from(tool));
1059            }
1060        }
1061        let response_tools = if response_tools.is_empty() {
1062            None
1063        } else {
1064            Some(response_tools)
1065        };
1066        let has_tools = response_tools.is_some();
1067        let tool_choice = responses_tool_choice(&snapshot, has_tools);
1068
1069        let request = ResponsesRequest::new(
1070            cfg.chat_model.clone(),
1071            input,
1072            &snapshot,
1073            response_tools,
1074            tool_choice,
1075            true, // stream: true
1076        );
1077        let serialized_request = serde_json::to_string(&request).unwrap_or_default();
1078
1079        tracing::debug!(request = %serde_json::to_string_pretty(&request).unwrap_or_default(), "Sending responses request");
1080
1081        // Make request with retry
1082        let sse_stream = match sse_request_with_retry(&cfg, || responses_request(&cfg, &request)).await {
1083            Ok(stream) => stream,
1084            Err(e) => {
1085                yield Err(e);
1086                return;
1087            }
1088        };
1089        futures_lite::pin!(sse_stream);
1090
1091        // Accumulate function calls by output_index because provider item identifiers can be
1092        // absent or empty on some streamed events.
1093        let mut function_calls: HashMap<usize, FunctionCallAccumulator> = HashMap::new();
1094        let mut usage: Option<Usage> = None;
1095        let mut usage_emitted = false;
1096
1097        while let Some(event) = sse_stream.next().await {
1098            match event {
1099                Ok(e) => {
1100                    if should_skip_event(&e) {
1101                        continue;
1102                    }
1103                    let data = e.text_data();
1104                    if data == "[DONE]" {
1105                        continue;
1106                    }
1107                    tracing::trace!(sse_event = %data, "Received Responses API SSE event");
1108
1109                    // Check for API error response
1110                    if let Ok(error_obj) = serde_json::from_str::<serde_json::Value>(data)
1111                        && let Some(error) = error_obj.get("error") {
1112                            let msg = error.get("message")
1113                                .and_then(|m| m.as_str())
1114                                .unwrap_or("Unknown API error");
1115                            if msg.contains("call_id") {
1116                                yield Err(OpenAIError::Api(format!(
1117                                    "{msg}\nSerialized responses request: {serialized_request}"
1118                                )));
1119                            } else {
1120                                yield Err(OpenAIError::Api(msg.to_string()));
1121                            }
1122                            return;
1123                        }
1124
1125                    match serde_json::from_str::<ResponsesStreamEvent>(data) {
1126                        Ok(stream_event) => {
1127                            match stream_event {
1128                                ResponsesStreamEvent::ResponseCreated { response } => {
1129                                    if let Some(meta) = response.and_then(|resp| resp.usage) {
1130                                        usage = Some(usage_from_responses(&meta));
1131                                    }
1132                                }
1133                                ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
1134                                    if !delta.is_empty() {
1135                                        yield Ok(Event::Text(delta));
1136                                    }
1137                                }
1138                                ResponsesStreamEvent::ReasoningTextDelta { delta, .. } |
1139                                ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
1140                                    if include_reasoning && !delta.is_empty() {
1141                                        yield Ok(Event::Reasoning(delta));
1142                                    }
1143                                }
1144                                ResponsesStreamEvent::OutputItemAdded { item, item_id, output_index } => {
1145                                    // When a function_call item is added, capture id and name
1146                                    if let ResponsesOutputItem::FunctionCall { id, call_id, name, .. } = item {
1147                                        let fallback_id = non_empty_response_id(item_id.clone())
1148                                            .or_else(|| non_empty_response_id(Some(id.clone())))
1149                                            .unwrap_or_else(|| fallback_response_item_id(output_index));
1150                                        let acc = function_calls.entry(output_index).or_default();
1151                                        acc.item_id = non_empty_response_id(item_id)
1152                                            .or_else(|| non_empty_response_id(Some(id)));
1153                                        acc.call_id = Some(normalize_response_call_id(call_id, &fallback_id));
1154                                        acc.name = Some(name.clone());
1155                                        // Emit initial delta with tool name
1156                                        if let Some(ref call_id) = acc.call_id {
1157                                            yield Ok(Event::ToolCallDelta {
1158                                                id: call_id.clone(),
1159                                                name,
1160                                                arguments_fragment: String::new(),
1161                                            });
1162                                        }
1163                                    }
1164                                }
1165                                ResponsesStreamEvent::FunctionCallArgumentsDelta { delta, output_index, .. } => {
1166                                    let acc = function_calls.entry(output_index).or_default();
1167                                    acc.arguments.push_str(&delta);
1168                                    // Emit delta for UI progress
1169                                    if let (Some(call_id), Some(name)) = (&acc.call_id, &acc.name) {
1170                                        yield Ok(Event::ToolCallDelta {
1171                                            id: call_id.clone(),
1172                                            name: name.clone(),
1173                                            arguments_fragment: acc.arguments.clone(),
1174                                        });
1175                                    }
1176                                }
1177                                ResponsesStreamEvent::FunctionCallArgumentsDone { arguments, output_index, .. } => {
1178                                    let acc = function_calls.entry(output_index).or_default();
1179                                    acc.arguments = arguments;
1180                                }
1181                                ResponsesStreamEvent::OutputItemDone { item, item_id, output_index } => {
1182                                    // Emit function call when item is done
1183                                    if let ResponsesOutputItem::FunctionCall { id, call_id, name, arguments } = item {
1184                                        let tool_call = mark_and_emit_done_function_call(
1185                                            &mut function_calls,
1186                                            output_index,
1187                                            item_id,
1188                                            id,
1189                                            call_id,
1190                                            name,
1191                                            &arguments,
1192                                        );
1193                                        yield Ok(Event::ToolCall(tool_call));
1194                                    } else if let ResponsesOutputItem::Reasoning {
1195                                        id, encrypted_content, ..
1196                                    } = item
1197                                    {
1198                                        // Only encrypted reasoning is replayable;
1199                                        // a summary alone cannot be verified.
1200                                        if let Some(encrypted) = encrypted_content {
1201                                            let payload = serde_json::json!({
1202                                                "type": "reasoning",
1203                                                "id": id,
1204                                                "encrypted_content": encrypted,
1205                                            });
1206                                            yield Ok(Event::ReasoningState(ReasoningState::new(
1207                                                PROVIDER_NAME,
1208                                                payload.to_string(),
1209                                            )));
1210                                        }
1211                                    }
1212                                }
1213                                ResponsesStreamEvent::ResponseCompleted { response } => {
1214                                    if let Some(meta) = response.usage {
1215                                        let final_usage = usage_from_responses(&meta);
1216                                        usage = Some(final_usage.clone());
1217                                        yield Ok(Event::Usage(final_usage));
1218                                        usage_emitted = true;
1219                                    }
1220                                }
1221                                ResponsesStreamEvent::ResponseFailed { error } => {
1222                                    let msg = error
1223                                        .and_then(|e| e.message)
1224                                        .unwrap_or_else(|| "Response failed".to_string());
1225                                    if msg.contains("call_id") {
1226                                        yield Err(OpenAIError::Api(format!(
1227                                            "{msg}\nSerialized responses request: {serialized_request}"
1228                                        )));
1229                                    } else {
1230                                        yield Err(OpenAIError::Api(msg));
1231                                    }
1232                                    return;
1233                                }
1234                                ResponsesStreamEvent::Error { message, .. } => {
1235                                    let msg = message.unwrap_or_else(|| "Unknown error".to_string());
1236                                    if msg.contains("call_id") {
1237                                        yield Err(OpenAIError::Api(format!(
1238                                            "{msg}\nSerialized responses request: {serialized_request}"
1239                                        )));
1240                                    } else {
1241                                        yield Err(OpenAIError::Api(msg));
1242                                    }
1243                                    return;
1244                                }
1245                                // Ignore other events
1246                                _ => {}
1247                            }
1248                        }
1249                        Err(e) => {
1250                            tracing::warn!(error = %e, data = %data, "Failed to parse Responses API event");
1251                        }
1252                    }
1253                }
1254                Err(e) => {
1255                    yield Err(OpenAIError::from(e));
1256                    return;
1257                }
1258            }
1259        }
1260
1261        // Emit any remaining accumulated function calls (fallback if OutputItemDone wasn't received)
1262        for tool_call in drain_pending_function_calls(function_calls) {
1263            yield Ok(Event::ToolCall(tool_call));
1264        }
1265        if !usage_emitted && let Some(final_usage) = usage {
1266            yield Ok(Event::Usage(final_usage));
1267        }
1268    }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    use super::*;
1274    use crate::response::{
1275        ChatCompletionTokensDetails, ChatCompletionUsage, ChatPromptTokensDetails,
1276        ResponsesInputTokenDetails, ResponsesOutputTokenDetails, ResponsesUsage,
1277    };
1278    use std::time::Duration;
1279
1280    #[test]
1281    fn done_function_call_is_not_emitted_twice() {
1282        let mut function_calls = HashMap::new();
1283        let emitted = mark_and_emit_done_function_call(
1284            &mut function_calls,
1285            1,
1286            Some("item_1".to_string()),
1287            "item_1".to_string(),
1288            Some("call_1".to_string()),
1289            "search".to_string(),
1290            r#"{"q":"rust"}"#,
1291        );
1292        assert_eq!(emitted.id, "call_1");
1293        assert_eq!(emitted.name, "search");
1294        assert_eq!(drain_pending_function_calls(function_calls).len(), 0);
1295    }
1296
1297    #[test]
1298    fn pending_function_call_is_emitted_once_via_fallback() {
1299        let mut function_calls = HashMap::new();
1300        function_calls.insert(
1301            2,
1302            FunctionCallAccumulator {
1303                item_id: Some("item_2".to_string()),
1304                call_id: Some("call_2".to_string()),
1305                name: Some("lookup".to_string()),
1306                arguments: r#"{"id":42}"#.to_string(),
1307                emitted: false,
1308            },
1309        );
1310        let pending = drain_pending_function_calls(function_calls);
1311        assert_eq!(pending.len(), 1);
1312        assert_eq!(pending[0].id, "call_2");
1313        assert_eq!(pending[0].name, "lookup");
1314    }
1315
1316    #[test]
1317    fn empty_done_call_id_falls_back_to_item_id() {
1318        let mut function_calls = HashMap::new();
1319        let emitted = mark_and_emit_done_function_call(
1320            &mut function_calls,
1321            3,
1322            Some("item_3".to_string()),
1323            "item_3".to_string(),
1324            Some(String::new()),
1325            "search".to_string(),
1326            r#"{"q":"terminal first"}"#,
1327        );
1328        assert_eq!(emitted.id, "item_3");
1329    }
1330
1331    #[test]
1332    fn empty_pending_call_id_falls_back_to_item_id() {
1333        let mut function_calls = HashMap::new();
1334        function_calls.insert(
1335            4,
1336            FunctionCallAccumulator {
1337                item_id: Some("item_4".to_string()),
1338                call_id: Some("   ".to_string()),
1339                name: Some("lookup".to_string()),
1340                arguments: r#"{"id":7}"#.to_string(),
1341                emitted: false,
1342            },
1343        );
1344        let pending = drain_pending_function_calls(function_calls);
1345        assert_eq!(pending.len(), 1);
1346        assert_eq!(pending[0].id, "item_4");
1347        assert_eq!(pending[0].name, "lookup");
1348    }
1349
1350    #[test]
1351    fn empty_stream_identifiers_fall_back_to_output_index() {
1352        let mut function_calls = HashMap::new();
1353        function_calls.insert(
1354            12,
1355            FunctionCallAccumulator {
1356                item_id: None,
1357                call_id: None,
1358                name: None,
1359                arguments: r#"{"q":"may"}"#.to_string(),
1360                emitted: false,
1361            },
1362        );
1363        let emitted = mark_and_emit_done_function_call(
1364            &mut function_calls,
1365            12,
1366            None,
1367            String::new(),
1368            Some(String::new()),
1369            "search".to_string(),
1370            r#"{"q":"may"}"#,
1371        );
1372        assert_eq!(emitted.id, "response_output_12");
1373    }
1374
1375    #[test]
1376    fn serialized_responses_request_rejects_empty_call_id() {
1377        let snapshot = ParameterSnapshot::from(&aither_core::llm::model::Parameters::default());
1378        let request = ResponsesRequest::new(
1379            "gpt-5.4".to_string(),
1380            vec![ResponsesInputItem::FunctionCall {
1381                kind: "function_call",
1382                call_id: String::new(),
1383                name: "search".to_string(),
1384                arguments: "{}".to_string(),
1385            }],
1386            &snapshot,
1387            None,
1388            responses_tool_choice(&snapshot, false),
1389            false,
1390        );
1391
1392        let error = validate_serialized_responses_request(&request)
1393            .expect_err("empty serialized call_id must fail");
1394        assert!(
1395            error.to_string().contains("input[0]"),
1396            "unexpected error: {error}"
1397        );
1398    }
1399
1400    #[test]
1401    fn serialized_responses_request_rejects_whitespace_call_id() {
1402        let snapshot = ParameterSnapshot::from(&aither_core::llm::model::Parameters::default());
1403        let request = ResponsesRequest::new(
1404            "gpt-5.4".to_string(),
1405            vec![ResponsesInputItem::FunctionCallOutput {
1406                kind: "function_call_output",
1407                call_id: "   ".to_string(),
1408                output: "{}".to_string(),
1409            }],
1410            &snapshot,
1411            None,
1412            responses_tool_choice(&snapshot, false),
1413            false,
1414        );
1415
1416        let error = validate_serialized_responses_request(&request)
1417            .expect_err("whitespace serialized call_id must fail");
1418        assert!(
1419            error.to_string().contains("blank call_id"),
1420            "unexpected error: {error}"
1421        );
1422    }
1423
1424    #[tokio::test]
1425    async fn configured_timeout_is_enforced_for_sse_requests() {
1426        let timeout = request_with_timeout(
1427            Duration::from_millis(20),
1428            futures_lite::future::pending::<Result<(), zenwave::Error>>(),
1429        )
1430        .await;
1431        assert!(matches!(timeout, Err(OpenAIError::Timeout)));
1432    }
1433
1434    #[test]
1435    fn chat_usage_mapping_keeps_reasoning_and_cache_tokens() {
1436        let usage = ChatCompletionUsage {
1437            prompt_tokens: Some(11),
1438            completion_tokens: Some(7),
1439            total_tokens: Some(18),
1440            prompt_tokens_details: Some(ChatPromptTokensDetails {
1441                cached_tokens: Some(3),
1442            }),
1443            completion_tokens_details: Some(ChatCompletionTokensDetails {
1444                reasoning_tokens: Some(2),
1445            }),
1446        };
1447        let mapped = usage_from_chat_completion(&usage);
1448        assert_eq!(mapped.prompt_tokens, Some(11));
1449        assert_eq!(mapped.completion_tokens, Some(7));
1450        assert_eq!(mapped.total_tokens, Some(18));
1451        assert_eq!(mapped.reasoning_tokens, Some(2));
1452        assert_eq!(mapped.cache_read_tokens, Some(3));
1453    }
1454
1455    #[test]
1456    fn responses_usage_mapping_keeps_reasoning_and_cache_tokens() {
1457        let usage = ResponsesUsage {
1458            input_tokens: Some(19),
1459            output_tokens: Some(5),
1460            total_tokens: Some(24),
1461            input_token_details: Some(ResponsesInputTokenDetails {
1462                cached_tokens: Some(4),
1463            }),
1464            output_token_details: Some(ResponsesOutputTokenDetails {
1465                reasoning_tokens: Some(1),
1466            }),
1467        };
1468        let mapped = usage_from_responses(&usage);
1469        assert_eq!(mapped.prompt_tokens, Some(19));
1470        assert_eq!(mapped.completion_tokens, Some(5));
1471        assert_eq!(mapped.total_tokens, Some(24));
1472        assert_eq!(mapped.reasoning_tokens, Some(1));
1473        assert_eq!(mapped.cache_read_tokens, Some(4));
1474    }
1475}
1476
1477fn filter_tool_definitions(
1478    defs: Vec<aither_core::llm::tool::ToolDefinition>,
1479    choice: &ToolChoice,
1480) -> Vec<aither_core::llm::tool::ToolDefinition> {
1481    match choice {
1482        ToolChoice::None => Vec::new(),
1483        ToolChoice::Exact(name) => defs
1484            .into_iter()
1485            .filter(|tool| tool.name() == name)
1486            .collect(),
1487        ToolChoice::Auto | ToolChoice::Required => defs,
1488    }
1489}
1490
1491/// Builder for [`OpenAI`] clients.
1492#[derive(Debug)]
1493pub struct Builder {
1494    api_key: String,
1495    base_url: String,
1496    api_kind: ApiKind,
1497    chat_model: String,
1498    embedding_model: String,
1499    embedding_dimensions: usize,
1500    image_model: String,
1501    audio_model: String,
1502    audio_voice: String,
1503    audio_format: String,
1504    transcription_model: String,
1505    moderation_model: String,
1506    legacy_max_tokens: bool,
1507    organization: Option<String>,
1508    native_abilities: Vec<Ability>,
1509    retry: RetryConfig,
1510    request_timeout: Duration,
1511}
1512
1513/// Default request timeout (5 minutes - generous for long completions).
1514const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
1515
1516impl Builder {
1517    fn new(api_key: impl Into<String>) -> Self {
1518        Self {
1519            api_key: api_key.into(),
1520            base_url: DEFAULT_BASE_URL.to_string(),
1521            api_kind: ApiKind::Responses,
1522            chat_model: DEFAULT_MODEL.to_string(),
1523            embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(),
1524            embedding_dimensions: DEFAULT_EMBEDDING_DIM,
1525            image_model: DEFAULT_IMAGE_MODEL.to_string(),
1526            audio_model: DEFAULT_AUDIO_MODEL.to_string(),
1527            audio_voice: DEFAULT_AUDIO_VOICE.to_string(),
1528            audio_format: DEFAULT_AUDIO_FORMAT.to_string(),
1529            transcription_model: DEFAULT_TRANSCRIPTION_MODEL.to_string(),
1530            moderation_model: DEFAULT_MODERATION_MODEL.to_string(),
1531            legacy_max_tokens: false,
1532            organization: None,
1533            native_abilities: Vec::new(),
1534            retry: RetryConfig::default(),
1535            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1536        }
1537    }
1538
1539    /// Set a custom API base URL.
1540    #[must_use]
1541    pub fn base_url(mut self, url: impl Into<String>) -> Self {
1542        self.base_url = url.into();
1543        self
1544    }
1545
1546    /// Use the recommended Responses API.
1547    #[must_use]
1548    pub const fn use_responses_api(mut self) -> Self {
1549        self.api_kind = ApiKind::Responses;
1550        self
1551    }
1552
1553    /// Use the legacy Chat Completions API (deprecated by `OpenAI`).
1554    #[must_use]
1555    pub const fn use_chat_completions_api(mut self) -> Self {
1556        self.api_kind = ApiKind::ChatCompletions;
1557        self
1558    }
1559
1560    /// Send deprecated `max_tokens` alongside `max_completion_tokens` for compatibility.
1561    ///
1562    /// `OpenAI` deprecates `max_tokens` and it is incompatible with reasoning models.
1563    #[must_use]
1564    pub const fn legacy_max_tokens(mut self, enabled: bool) -> Self {
1565        self.legacy_max_tokens = enabled;
1566        self
1567    }
1568
1569    /// Select a model identifier (e.g., `gpt-4o-mini`, `o1-mini`).
1570    #[must_use]
1571    pub fn model(mut self, model: impl Into<String>) -> Self {
1572        self.chat_model = sanitize_model(model);
1573        self
1574    }
1575
1576    /// Select the embeddings model identifier.
1577    #[must_use]
1578    pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
1579        let model = sanitize_model(model);
1580        if let Some(dim) = infer_embedding_dim(&model) {
1581            self.embedding_dimensions = dim;
1582        }
1583        self.embedding_model = model;
1584        self
1585    }
1586
1587    /// Override the embedding vector dimension.
1588    #[must_use]
1589    pub const fn embedding_dimensions(mut self, dimensions: usize) -> Self {
1590        self.embedding_dimensions = dimensions;
1591        self
1592    }
1593
1594    /// Select the image model identifier.
1595    #[must_use]
1596    pub fn image_model(mut self, model: impl Into<String>) -> Self {
1597        self.image_model = sanitize_model(model);
1598        self
1599    }
1600
1601    /// Select the default audio generation model.
1602    #[must_use]
1603    pub fn audio_model(mut self, model: impl Into<String>) -> Self {
1604        self.audio_model = sanitize_model(model);
1605        self
1606    }
1607
1608    /// Override the default TTS voice.
1609    #[must_use]
1610    pub fn audio_voice(mut self, voice: impl Into<String>) -> Self {
1611        self.audio_voice = voice.into();
1612        self
1613    }
1614
1615    /// Override the synthesized audio format.
1616    #[must_use]
1617    pub fn audio_format(mut self, format: impl Into<String>) -> Self {
1618        self.audio_format = format.into();
1619        self
1620    }
1621
1622    /// Select the transcription model identifier.
1623    #[must_use]
1624    pub fn transcription_model(mut self, model: impl Into<String>) -> Self {
1625        self.transcription_model = sanitize_model(model);
1626        self
1627    }
1628
1629    /// Select the moderation model identifier.
1630    #[must_use]
1631    pub fn moderation_model(mut self, model: impl Into<String>) -> Self {
1632        self.moderation_model = sanitize_model(model);
1633        self
1634    }
1635
1636    /// Attach an `OpenAI` organization header.
1637    #[must_use]
1638    pub fn organization(mut self, organization: impl Into<String>) -> Self {
1639        self.organization = Some(organization.into());
1640        self
1641    }
1642
1643    /// Declare extra native capabilities (e.g., web search, PDF understanding) supported by the upstream model.
1644    #[must_use]
1645    pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
1646        for ability in abilities {
1647            if !self.native_abilities.contains(&ability) {
1648                self.native_abilities.push(ability);
1649            }
1650        }
1651        self
1652    }
1653
1654    /// Mark this model as having built-in web search support.
1655    #[must_use]
1656    pub fn enable_native_web_search(self) -> Self {
1657        self.native_capabilities([Ability::WebSearch])
1658    }
1659
1660    /// Mark this model as having native PDF/document understanding.
1661    #[must_use]
1662    pub fn enable_native_pdf(self) -> Self {
1663        self.native_capabilities([Ability::Pdf])
1664    }
1665
1666    /// Configure retry behavior for failed requests.
1667    ///
1668    /// By default, requests are retried up to 3 times with exponential backoff.
1669    /// Retries happen on network errors and certain HTTP status codes (429, 500, 502, 503, 504).
1670    #[must_use]
1671    pub const fn retry(mut self, config: RetryConfig) -> Self {
1672        self.retry = config;
1673        self
1674    }
1675
1676    /// Set maximum number of retry attempts.
1677    #[must_use]
1678    pub const fn max_retries(mut self, max_retries: u32) -> Self {
1679        self.retry.max_retries = max_retries;
1680        self
1681    }
1682
1683    /// Disable retries entirely.
1684    #[must_use]
1685    pub fn no_retry(mut self) -> Self {
1686        self.retry = RetryConfig::none();
1687        self
1688    }
1689
1690    /// Set the request timeout.
1691    ///
1692    /// Default is 5 minutes, which is generous for long completions.
1693    /// The timeout applies to the entire request, including connection and response streaming.
1694    #[must_use]
1695    pub const fn timeout(mut self, timeout: Duration) -> Self {
1696        self.request_timeout = timeout;
1697        self
1698    }
1699
1700    /// Consume the builder and create an [`OpenAI`] client.
1701    #[must_use]
1702    pub fn build(self) -> OpenAI {
1703        OpenAI {
1704            inner: Arc::new(Config {
1705                api_key: self.api_key,
1706                base_url: self.base_url,
1707                api_kind: self.api_kind,
1708                chat_model: self.chat_model,
1709                embedding_model: self.embedding_model,
1710                embedding_dimensions: self.embedding_dimensions,
1711                image_model: self.image_model,
1712                audio_model: self.audio_model,
1713                audio_voice: self.audio_voice,
1714                audio_format: self.audio_format,
1715                transcription_model: self.transcription_model,
1716                moderation_model: self.moderation_model,
1717                legacy_max_tokens: self.legacy_max_tokens,
1718                organization: self.organization,
1719                native_abilities: self.native_abilities,
1720                retry: self.retry,
1721                request_timeout: self.request_timeout,
1722            }),
1723        }
1724    }
1725}
1726
1727#[derive(Debug, Clone)]
1728pub struct Config {
1729    pub(crate) api_key: String,
1730    pub(crate) base_url: String,
1731    pub(crate) api_kind: ApiKind,
1732    pub(crate) chat_model: String,
1733    pub(crate) embedding_model: String,
1734    pub(crate) embedding_dimensions: usize,
1735    pub(crate) image_model: String,
1736    pub(crate) audio_model: String,
1737    pub(crate) audio_voice: String,
1738    pub(crate) audio_format: String,
1739    pub(crate) transcription_model: String,
1740    pub(crate) moderation_model: String,
1741    pub(crate) legacy_max_tokens: bool,
1742    pub(crate) organization: Option<String>,
1743    pub(crate) native_abilities: Vec<Ability>,
1744    pub(crate) retry: RetryConfig,
1745    pub(crate) request_timeout: Duration,
1746}
1747
1748impl Config {
1749    pub(crate) fn request_url(&self, path: &str) -> String {
1750        format!(
1751            "{}/{}",
1752            self.base_url.trim_end_matches('/'),
1753            path.trim_start_matches('/')
1754        )
1755    }
1756
1757    pub(crate) fn request_auth(&self) -> String {
1758        format!("Bearer {}", self.api_key)
1759    }
1760}
1761
1762fn sanitize_model(model: impl Into<String>) -> String {
1763    model.into().trim().to_string()
1764}
1765
1766fn infer_embedding_dim(model: &str) -> Option<usize> {
1767    match model {
1768        "text-embedding-3-large" => Some(3072),
1769        "text-embedding-3-small" | "text-embedding-ada-002" => Some(1536),
1770        _ => None,
1771    }
1772}