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