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