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