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