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