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