Skip to main content

aither_openai/
client.rs

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