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 = ModelProfile> + 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 .unwrap_or_else(|| {
512 tracing::warn!(
517 model = %cfg.chat_model,
518 assumed_context_length = UNKNOWN_MODEL_CONTEXT_LENGTH,
519 "model missing context metadata from provider and aither-models"
520 );
521 UNKNOWN_MODEL_CONTEXT_LENGTH
522 })
523 }
524 };
525
526 let mut profile = ModelProfile::new(
527 cfg.chat_model.clone(),
528 "OpenAI",
529 cfg.chat_model.clone(),
530 "OpenAI GPT family model",
531 context_length,
532 )
533 .with_ability(Ability::ToolUse);
534 for ability in &cfg.native_abilities {
535 if !profile.abilities.contains(ability) {
536 profile.abilities.push(*ability);
537 }
538 }
539 profile
540 }
541 }
542}
543
544const UNKNOWN_MODEL_CONTEXT_LENGTH: u32 = 32_768;
550
551async fn fetch_model_context_length(cfg: &Config) -> Result<u32, OpenAIError> {
552 use crate::response::ModelsListResponse;
553
554 let url = format!("{}/models", cfg.base_url.trim_end_matches('/'));
555 let mut backend = client();
556 let mut builder = backend
557 .get(&url)
558 .map_err(OpenAIError::Http)?
559 .header(
560 header::AUTHORIZATION.as_str(),
561 format!("Bearer {}", cfg.api_key),
562 )
563 .map_err(OpenAIError::Http)?;
564 for (name, value) in &cfg.extra_headers {
565 builder = builder
566 .header(name.clone(), value.clone())
567 .map_err(OpenAIError::Http)?;
568 }
569 let response: ModelsListResponse =
570 request_with_timeout(cfg.request_timeout, builder.json()).await?;
571
572 let mut model_found = false;
574 for model in response.data {
575 if model.id == cfg.chat_model {
576 model_found = true;
577 if let Some(ctx) = model.context_length.or(model.max_tokens) {
578 tracing::debug!(
579 "Fetched context_length={} for model '{}'",
580 ctx,
581 cfg.chat_model
582 );
583 return Ok(ctx);
584 }
585 }
586 }
587
588 if model_found {
590 Err(OpenAIError::Api(format!(
591 "Model '{}' found but missing context_length field (proxy may not support it)",
592 cfg.chat_model
593 )))
594 } else {
595 Err(OpenAIError::Api(format!(
596 "Model '{}' not found in /models response",
597 cfg.chat_model
598 )))
599 }
600}
601
602fn chat_completions_stream(
603 cfg: Arc<Config>,
604 payload_messages: Vec<ChatMessagePayload>,
605 snapshot: ParameterSnapshot,
606 openai_tools: Option<Vec<ToolPayload>>,
607) -> impl Stream<Item = Result<Event, OpenAIError>> + Send + Unpin {
608 Box::pin(chat_completions_stream_inner(
609 cfg,
610 payload_messages,
611 snapshot,
612 openai_tools,
613 ))
614}
615
616#[derive(Debug, Default)]
618struct ToolCallAccumulator {
619 id: Option<String>,
620 name: Option<String>,
621 arguments: String,
622 emitted: usize,
624 announced: bool,
626}
627
628fn accumulate_tool_call_delta<'a>(
635 tool_calls: &'a mut HashMap<usize, ToolCallAccumulator>,
636 call: &DeltaToolCall,
637) -> &'a mut ToolCallAccumulator {
638 let index = call.index.unwrap_or(0);
639 let acc = tool_calls.entry(index).or_default();
640 if let Some(id) = &call.id
641 && !id.is_empty()
642 {
643 acc.id = Some(id.clone());
644 }
645 if let Some(function) = &call.function {
646 if let Some(name) = &function.name
647 && !name.is_empty()
648 {
649 acc.name = Some(name.clone());
650 }
651 if let Some(args) = &function.arguments {
652 acc.arguments.push_str(args);
653 }
654 }
655 acc
656}
657
658async fn chat_completions_request(
660 cfg: &Config,
661 request: &ChatCompletionRequest,
662) -> SseStreamResult {
663 let endpoint = cfg.request_url("/chat/completions");
664 let mut backend = client();
665
666 let build_result = backend
667 .post(endpoint)
668 .and_then(|b| b.header(header::AUTHORIZATION.as_str(), cfg.request_auth()))
669 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-openai/0.1"))
670 .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"));
671
672 let mut builder = build_result.map_err(OpenAIError::Http)?;
673
674 if let Some(org) = &cfg.organization {
675 builder = builder
676 .header("OpenAI-Organization", org.clone())
677 .map_err(OpenAIError::Http)?;
678 }
679 for (name, value) in &cfg.extra_headers {
680 builder = builder
681 .header(name.clone(), value.clone())
682 .map_err(OpenAIError::Http)?;
683 }
684
685 request_with_timeout(
686 cfg.request_timeout,
687 builder.json_body(request).map_err(OpenAIError::Http)?.sse(),
688 )
689 .await
690}
691
692#[allow(clippy::too_many_lines)]
693fn chat_completions_stream_inner(
694 cfg: Arc<Config>,
695 payload_messages: Vec<ChatMessagePayload>,
696 snapshot: ParameterSnapshot,
697 openai_tools: Option<Vec<ToolPayload>>,
698) -> impl Stream<Item = Result<Event, OpenAIError>> + Send {
699 let include_reasoning = snapshot.include_reasoning;
700
701 async_stream::stream! {
702 let request = ChatCompletionRequest::new(
703 cfg.chat_model.clone(),
704 payload_messages,
705 &snapshot,
706 openai_tools,
707 true,
708 );
709
710 tracing::debug!(request = %serde_json::to_string_pretty(&request).unwrap_or_default(), "Sending chat completion request");
711
712 let sse_stream = match sse_request_with_retry(&cfg, || chat_completions_request(&cfg, &request)).await {
714 Ok(stream) => stream,
715 Err(e) => {
716 yield Err(e);
717 return;
718 }
719 };
720 futures_lite::pin!(sse_stream);
721
722 let mut tool_calls: std::collections::HashMap<usize, ToolCallAccumulator> =
724 std::collections::HashMap::new();
725 let mut usage: Option<Usage> = None;
726 while let Some(event) = sse_stream.next().await {
727 match event {
728 Ok(e) => {
729 if should_skip_event(&e) {
730 continue;
731 }
732 let data = e.text_data();
733 if data == "[DONE]" {
734 continue;
735 }
736 tracing::debug!(sse_event = %data, "Received SSE event");
737
738 if let Ok(error_obj) = serde_json::from_str::<serde_json::Value>(data)
740 && let Some(error) = error_obj.get("error") {
741 let msg = error.get("message")
742 .and_then(|m| m.as_str())
743 .unwrap_or("Unknown API error");
744 yield Err(OpenAIError::Api(msg.to_string()));
745 return;
746 }
747
748 match serde_json::from_str::<ChatCompletionChunk>(data) {
749 Ok(chunk) => {
750 if let Some(chunk_usage) = &chunk.usage {
751 usage = Some(usage_from_chat_completion(chunk_usage));
752 }
753 for choice in &chunk.choices {
755 if let Some(ref reason) = choice.finish_reason
757 && reason.contains("malformed_function_call") {
758 yield Err(OpenAIError::Api("malformed function call".to_string()));
759 return;
760 }
761
762 if let Some(content) = &choice.delta.content
763 && !content.is_empty() {
764 yield Ok(Event::Text(content.clone()));
765 }
766 if include_reasoning
768 && let Some(reasoning) = &choice.delta.reasoning_content
769 && !reasoning.is_empty() {
770 yield Ok(Event::Reasoning(reasoning.clone()));
771 }
772 if let Some(calls) = &choice.delta.tool_calls {
774 for call in calls {
775 let acc = accumulate_tool_call_delta(&mut tool_calls, call);
776 if let (Some(id), Some(name)) = (&acc.id, &acc.name) {
782 let fragment =
783 acc.arguments[acc.emitted..].to_string();
784 if !fragment.is_empty() || !acc.announced {
785 acc.emitted = acc.arguments.len();
786 acc.announced = true;
787 yield Ok(Event::ToolCallDelta {
788 id: id.clone(),
789 name: name.clone(),
790 arguments_fragment: fragment,
791 });
792 }
793 }
794 }
795 }
796 }
797 }
798 Err(e) => {
799 yield Err(OpenAIError::from(e));
800 return;
801 }
802 }
803 }
804 Err(e) => {
805 yield Err(OpenAIError::from(e));
806 return;
807 }
808 }
809 }
810
811 let mut sorted_calls: Vec<_> = tool_calls.into_iter().collect();
813 sorted_calls.sort_by_key(|(index, _)| *index);
814 for (_, acc) in sorted_calls {
815 if let (Some(id), Some(name)) = (acc.id, acc.name) {
816 let arguments = if acc.arguments.is_empty() {
817 serde_json::Value::Object(serde_json::Map::default())
818 } else {
819 serde_json::from_str(&acc.arguments).unwrap_or_else(|_| {
820 serde_json::Value::Object(serde_json::Map::default())
821 })
822 };
823 yield Ok(Event::ToolCall(ToolCall {
824 id,
825 name,
826 arguments,
827 reasoning_state: None,
828 }));
829 }
830 }
831 if let Some(final_usage) = usage {
832 yield Ok(Event::Usage(final_usage));
833 }
834 }
835}
836
837fn responses_stream(
838 cfg: Arc<Config>,
839 input: Vec<ResponsesInputItem>,
840 snapshot: ParameterSnapshot,
841 tool_defs: Vec<aither_core::llm::tool::ToolDefinition>,
842) -> impl Stream<Item = Result<Event, OpenAIError>> + Send + Unpin {
843 Box::pin(responses_stream_inner(cfg, input, snapshot, tool_defs))
844}
845
846#[derive(Debug, Default)]
848struct FunctionCallAccumulator {
849 item_id: Option<String>,
850 call_id: Option<String>,
851 name: Option<String>,
852 arguments: String,
853 emitted: bool,
854}
855
856fn normalize_response_call_id(call_id: Option<String>, fallback: &str) -> String {
857 match call_id {
858 Some(call_id) if !call_id.trim().is_empty() => call_id,
859 _ => fallback.to_string(),
860 }
861}
862
863fn non_empty_response_id(value: Option<String>) -> Option<String> {
864 value.filter(|value| !value.trim().is_empty())
865}
866
867fn fallback_response_item_id(output_index: usize) -> String {
868 format!("response_output_{output_index}")
869}
870
871fn parse_tool_call_arguments(arguments: &str) -> serde_json::Value {
872 if arguments.is_empty() {
873 serde_json::Value::Object(serde_json::Map::default())
874 } else {
875 serde_json::from_str(arguments)
876 .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::default()))
877 }
878}
879
880fn usage_from_chat_completion(raw: &ChatCompletionUsage) -> Usage {
881 Usage {
882 prompt_tokens: raw.prompt_tokens,
883 completion_tokens: raw.completion_tokens,
884 total_tokens: raw.total_tokens,
885 reasoning_tokens: raw
886 .completion_tokens_details
887 .as_ref()
888 .and_then(|details| details.reasoning_tokens),
889 cache_read_tokens: raw
890 .prompt_tokens_details
891 .as_ref()
892 .and_then(|details| details.cached_tokens),
893 cache_write_tokens: None,
894 cost_usd: None,
895 stop_reason: None,
896 }
897}
898
899fn usage_from_responses(raw: &ResponsesUsage) -> Usage {
900 Usage {
901 prompt_tokens: raw.input_tokens,
902 completion_tokens: raw.output_tokens,
903 total_tokens: raw.total_tokens,
904 reasoning_tokens: raw
905 .output_token_details
906 .as_ref()
907 .and_then(|details| details.reasoning_tokens),
908 cache_read_tokens: raw
909 .input_token_details
910 .as_ref()
911 .and_then(|details| details.cached_tokens),
912 cache_write_tokens: None,
913 cost_usd: None,
914 stop_reason: None,
915 }
916}
917
918fn mark_and_emit_done_function_call(
919 function_calls: &mut HashMap<usize, FunctionCallAccumulator>,
920 output_index: usize,
921 item_id: Option<String>,
922 response_id: String,
923 call_id: Option<String>,
924 name: String,
925 arguments: &str,
926) -> ToolCall {
927 let fallback_id = non_empty_response_id(item_id.clone())
928 .or_else(|| non_empty_response_id(Some(response_id.clone())))
929 .unwrap_or_else(|| fallback_response_item_id(output_index));
930 let resolved_call_id = normalize_response_call_id(call_id, &fallback_id);
931
932 let acc = function_calls.entry(output_index).or_default();
933 acc.item_id =
934 non_empty_response_id(item_id).or_else(|| non_empty_response_id(Some(response_id)));
935 acc.call_id = Some(resolved_call_id.clone());
936 acc.name = Some(name.clone());
937 acc.arguments.clear();
938 acc.arguments.push_str(arguments);
939 acc.emitted = true;
940
941 ToolCall {
942 id: resolved_call_id,
943 name,
944 arguments: parse_tool_call_arguments(arguments),
945 reasoning_state: None,
946 }
947}
948
949fn drain_pending_function_calls(
950 function_calls: HashMap<usize, FunctionCallAccumulator>,
951) -> Vec<ToolCall> {
952 function_calls
953 .into_iter()
954 .filter(|(_, acc)| !acc.emitted)
955 .filter_map(|(output_index, acc)| {
956 let name = acc.name?;
957 let fallback_id = acc
958 .item_id
959 .clone()
960 .unwrap_or_else(|| fallback_response_item_id(output_index));
961 let call_id = normalize_response_call_id(acc.call_id, &fallback_id);
962 Some(ToolCall {
963 id: call_id,
964 name,
965 arguments: parse_tool_call_arguments(&acc.arguments),
966 reasoning_state: None,
967 })
968 })
969 .collect()
970}
971
972#[allow(clippy::items_after_statements)]
973fn validate_serialized_responses_request(request: &ResponsesRequest) -> Result<(), OpenAIError> {
974 let request_value = serde_json::to_value(request).map_err(OpenAIError::Json)?;
975 let Some(items) = request_value
976 .get("input")
977 .and_then(serde_json::Value::as_array)
978 else {
979 return Ok(());
980 };
981
982 fn find_blank_call_id(value: &serde_json::Value, path: &str) -> Option<(String, String)> {
983 match value {
984 serde_json::Value::Object(map) => {
985 for (key, child) in map {
986 let child_path = format!("{path}.{key}");
987 if key == "call_id"
988 && let Some(call_id) = child.as_str()
989 && call_id.trim().is_empty()
990 {
991 return Some((child_path, call_id.to_string()));
992 }
993 if let Some(found) = find_blank_call_id(child, &child_path) {
994 return Some(found);
995 }
996 }
997 None
998 }
999 serde_json::Value::Array(items) => {
1000 for (index, child) in items.iter().enumerate() {
1001 let child_path = format!("{path}[{index}]");
1002 if let Some(found) = find_blank_call_id(child, &child_path) {
1003 return Some(found);
1004 }
1005 }
1006 None
1007 }
1008 serde_json::Value::Null
1009 | serde_json::Value::Bool(_)
1010 | serde_json::Value::Number(_)
1011 | serde_json::Value::String(_) => None,
1012 }
1013 }
1014
1015 for (index, item) in items.iter().enumerate() {
1016 if let Some((path, call_id)) = find_blank_call_id(item, &format!("input[{index}]")) {
1017 let snippet = serde_json::to_string(item).map_err(OpenAIError::Json)?;
1018 return Err(OpenAIError::Api(format!(
1019 "responses request serialized blank call_id at {path}: {call_id:?}; item={snippet}"
1020 )));
1021 }
1022 }
1023 Ok(())
1024}
1025
1026async fn responses_request(cfg: &Config, request: &ResponsesRequest) -> SseStreamResult {
1028 validate_serialized_responses_request(request)?;
1029 let serialized_request = serde_json::to_string(request).map_err(OpenAIError::Json)?;
1030 let endpoint = cfg.request_url("/responses");
1031 let mut backend = client();
1032
1033 let build_result = backend
1034 .post(endpoint)
1035 .and_then(|b| b.header(header::AUTHORIZATION.as_str(), cfg.request_auth()))
1036 .and_then(|b| b.header(header::USER_AGENT.as_str(), "aither-openai/0.1"))
1037 .and_then(|b| b.header(header::ACCEPT.as_str(), "text/event-stream"));
1038
1039 let mut builder = build_result.map_err(OpenAIError::Http)?;
1040
1041 if let Some(org) = &cfg.organization {
1042 builder = builder
1043 .header("OpenAI-Organization", org.clone())
1044 .map_err(OpenAIError::Http)?;
1045 }
1046 for (name, value) in &cfg.extra_headers {
1047 builder = builder
1048 .header(name.clone(), value.clone())
1049 .map_err(OpenAIError::Http)?;
1050 }
1051
1052 match request_with_timeout(
1053 cfg.request_timeout,
1054 builder.json_body(request).map_err(OpenAIError::Http)?.sse(),
1055 )
1056 .await
1057 {
1058 Ok(stream) => Ok(stream),
1059 Err(error) => {
1060 if error.to_string().contains("call_id") {
1061 return Err(OpenAIError::Api(format!(
1062 "{error}\nSerialized responses request: {serialized_request}"
1063 )));
1064 }
1065 Err(error)
1066 }
1067 }
1068}
1069
1070#[allow(clippy::too_many_lines, clippy::collapsible_match)]
1071fn responses_stream_inner(
1072 cfg: Arc<Config>,
1073 input: Vec<ResponsesInputItem>,
1074 snapshot: ParameterSnapshot,
1075 tool_defs: Vec<aither_core::llm::tool::ToolDefinition>,
1076) -> impl Stream<Item = Result<Event, OpenAIError>> + Send {
1077 let include_reasoning = snapshot.include_reasoning;
1078
1079 async_stream::stream! {
1080 let mut response_tools = convert_responses_tools(tool_defs);
1081 let allow_builtins = matches!(snapshot.tool_choice, ToolChoice::Auto | ToolChoice::Required);
1082 if allow_builtins {
1083 let native_tools = snapshot.openai_tools.clone();
1084 if snapshot.websearch || native_tools.web_search.is_some() {
1085 let tool = native_tools.web_search.unwrap_or_default();
1086 response_tools.push(ResponsesTool::from(tool));
1087 }
1088 for tool in native_tools.file_search {
1089 response_tools.push(ResponsesTool::from(tool));
1090 }
1091 if snapshot.code_execution || native_tools.code_interpreter.is_some() {
1092 let tool = native_tools.code_interpreter.unwrap_or_default();
1093 response_tools.push(ResponsesTool::from(tool));
1094 }
1095 if let Some(tool) = native_tools.image_generation {
1096 response_tools.push(ResponsesTool::from(tool));
1097 }
1098 for tool in native_tools.mcp {
1099 response_tools.push(ResponsesTool::from(tool));
1100 }
1101 if let Some(tool) = native_tools.computer_use {
1102 response_tools.push(ResponsesTool::from(tool));
1103 }
1104 }
1105 let response_tools = if response_tools.is_empty() {
1106 None
1107 } else {
1108 Some(response_tools)
1109 };
1110 let has_tools = response_tools.is_some();
1111 let tool_choice = responses_tool_choice(&snapshot, has_tools);
1112
1113 let mut request = ResponsesRequest::new(
1114 cfg.chat_model.clone(),
1115 input,
1116 &snapshot,
1117 response_tools,
1118 tool_choice,
1119 true, );
1121 request.store = cfg.store;
1122 let serialized_request = serde_json::to_string(&request).unwrap_or_default();
1123
1124 tracing::debug!(request = %serde_json::to_string_pretty(&request).unwrap_or_default(), "Sending responses request");
1125
1126 let sse_stream = match sse_request_with_retry(&cfg, || responses_request(&cfg, &request)).await {
1128 Ok(stream) => stream,
1129 Err(e) => {
1130 yield Err(e);
1131 return;
1132 }
1133 };
1134 futures_lite::pin!(sse_stream);
1135
1136 let mut function_calls: HashMap<usize, FunctionCallAccumulator> = HashMap::new();
1139 let mut usage: Option<Usage> = None;
1140 let mut usage_emitted = false;
1141
1142 while let Some(event) = sse_stream.next().await {
1143 match event {
1144 Ok(e) => {
1145 if should_skip_event(&e) {
1146 continue;
1147 }
1148 let data = e.text_data();
1149 if data == "[DONE]" {
1150 continue;
1151 }
1152 tracing::trace!(sse_event = %data, "Received Responses API SSE event");
1153
1154 if let Ok(error_obj) = serde_json::from_str::<serde_json::Value>(data)
1156 && let Some(error) = error_obj.get("error") {
1157 let msg = error.get("message")
1158 .and_then(|m| m.as_str())
1159 .unwrap_or("Unknown API error");
1160 if msg.contains("call_id") {
1161 yield Err(OpenAIError::Api(format!(
1162 "{msg}\nSerialized responses request: {serialized_request}"
1163 )));
1164 } else {
1165 yield Err(OpenAIError::Api(msg.to_string()));
1166 }
1167 return;
1168 }
1169
1170 match serde_json::from_str::<ResponsesStreamEvent>(data) {
1171 Ok(stream_event) => {
1172 match stream_event {
1173 ResponsesStreamEvent::ResponseCreated { response } => {
1174 if let Some(meta) = response.and_then(|resp| resp.usage) {
1175 usage = Some(usage_from_responses(&meta));
1176 }
1177 }
1178 ResponsesStreamEvent::OutputTextDelta { delta, .. } => {
1179 if !delta.is_empty() {
1180 yield Ok(Event::Text(delta));
1181 }
1182 }
1183 ResponsesStreamEvent::ReasoningTextDelta { delta, .. } |
1184 ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => {
1185 if include_reasoning && !delta.is_empty() {
1186 yield Ok(Event::Reasoning(delta));
1187 }
1188 }
1189 ResponsesStreamEvent::OutputItemAdded { item, item_id, output_index } => {
1190 if let ResponsesOutputItem::FunctionCall { id, call_id, name, .. } = item {
1192 let fallback_id = non_empty_response_id(item_id.clone())
1193 .or_else(|| non_empty_response_id(Some(id.clone())))
1194 .unwrap_or_else(|| fallback_response_item_id(output_index));
1195 let acc = function_calls.entry(output_index).or_default();
1196 acc.item_id = non_empty_response_id(item_id)
1197 .or_else(|| non_empty_response_id(Some(id)));
1198 acc.call_id = Some(normalize_response_call_id(call_id, &fallback_id));
1199 acc.name = Some(name.clone());
1200 if let Some(ref call_id) = acc.call_id {
1202 yield Ok(Event::ToolCallDelta {
1203 id: call_id.clone(),
1204 name,
1205 arguments_fragment: String::new(),
1206 });
1207 }
1208 }
1209 }
1210 ResponsesStreamEvent::FunctionCallArgumentsDelta { delta, output_index, .. } => {
1211 let acc = function_calls.entry(output_index).or_default();
1212 acc.arguments.push_str(&delta);
1213 if let (Some(call_id), Some(name)) = (&acc.call_id, &acc.name) {
1215 yield Ok(Event::ToolCallDelta {
1216 id: call_id.clone(),
1217 name: name.clone(),
1218 arguments_fragment: delta,
1219 });
1220 }
1221 }
1222 ResponsesStreamEvent::FunctionCallArgumentsDone { arguments, output_index, .. } => {
1223 let acc = function_calls.entry(output_index).or_default();
1224 acc.arguments = arguments;
1225 }
1226 ResponsesStreamEvent::OutputItemDone { item, item_id, output_index } => {
1227 if let ResponsesOutputItem::FunctionCall { id, call_id, name, arguments } = item {
1229 let tool_call = mark_and_emit_done_function_call(
1230 &mut function_calls,
1231 output_index,
1232 item_id,
1233 id,
1234 call_id,
1235 name,
1236 &arguments,
1237 );
1238 yield Ok(Event::ToolCall(tool_call));
1239 } else if let ResponsesOutputItem::Reasoning {
1240 id, encrypted_content, ..
1241 } = item
1242 {
1243 if let Some(encrypted) = encrypted_content {
1246 let payload = serde_json::json!({
1247 "type": "reasoning",
1248 "id": id,
1249 "encrypted_content": encrypted,
1250 });
1251 yield Ok(Event::ReasoningState(ReasoningState::new(
1252 PROVIDER_NAME,
1253 payload.to_string(),
1254 )));
1255 }
1256 }
1257 }
1258 ResponsesStreamEvent::ResponseCompleted { response } => {
1259 if let Some(meta) = response.usage {
1260 let final_usage = usage_from_responses(&meta);
1261 usage = Some(final_usage.clone());
1262 yield Ok(Event::Usage(final_usage));
1263 usage_emitted = true;
1264 }
1265 }
1266 ResponsesStreamEvent::ResponseFailed { error } => {
1267 let msg = error
1268 .and_then(|e| e.message)
1269 .unwrap_or_else(|| "Response failed".to_string());
1270 if msg.contains("call_id") {
1271 yield Err(OpenAIError::Api(format!(
1272 "{msg}\nSerialized responses request: {serialized_request}"
1273 )));
1274 } else {
1275 yield Err(OpenAIError::Api(msg));
1276 }
1277 return;
1278 }
1279 ResponsesStreamEvent::Error { message, .. } => {
1280 let msg = message.unwrap_or_else(|| "Unknown error".to_string());
1281 if msg.contains("call_id") {
1282 yield Err(OpenAIError::Api(format!(
1283 "{msg}\nSerialized responses request: {serialized_request}"
1284 )));
1285 } else {
1286 yield Err(OpenAIError::Api(msg));
1287 }
1288 return;
1289 }
1290 _ => {}
1292 }
1293 }
1294 Err(e) => {
1295 tracing::warn!(error = %e, data = %data, "Failed to parse Responses API event");
1296 }
1297 }
1298 }
1299 Err(e) => {
1300 yield Err(OpenAIError::from(e));
1301 return;
1302 }
1303 }
1304 }
1305
1306 for tool_call in drain_pending_function_calls(function_calls) {
1308 yield Ok(Event::ToolCall(tool_call));
1309 }
1310 if !usage_emitted && let Some(final_usage) = usage {
1311 yield Ok(Event::Usage(final_usage));
1312 }
1313 }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318 use super::*;
1319 use crate::response::{
1320 ChatCompletionTokensDetails, ChatCompletionUsage, ChatPromptTokensDetails,
1321 DeltaToolFunction, ResponsesInputTokenDetails, ResponsesOutputTokenDetails, ResponsesUsage,
1322 };
1323 use std::time::Duration;
1324
1325 fn chat_delta(id: Option<&str>, name: Option<&str>, arguments: &str) -> DeltaToolCall {
1326 DeltaToolCall {
1327 index: Some(0),
1328 id: id.map(str::to_string),
1329 function: Some(DeltaToolFunction {
1330 name: name.map(str::to_string),
1331 arguments: Some(arguments.to_string()),
1332 }),
1333 }
1334 }
1335
1336 #[test]
1337 fn chat_tool_call_accumulates_name_and_arguments() {
1338 let mut tool_calls = HashMap::new();
1339 accumulate_tool_call_delta(
1340 &mut tool_calls,
1341 &chat_delta(Some("call_1"), Some("search"), ""),
1342 );
1343 accumulate_tool_call_delta(&mut tool_calls, &chat_delta(None, None, "{\"q\":"));
1344 accumulate_tool_call_delta(&mut tool_calls, &chat_delta(None, None, "\"rust\"}"));
1345
1346 let acc = &tool_calls[&0];
1347 assert_eq!(acc.id.as_deref(), Some("call_1"));
1348 assert_eq!(acc.name.as_deref(), Some("search"));
1349 assert_eq!(acc.arguments, "{\"q\":\"rust\"}");
1350 }
1351
1352 #[test]
1353 fn chat_tool_call_empty_delta_fields_do_not_overwrite() {
1354 let mut tool_calls = HashMap::new();
1355 accumulate_tool_call_delta(
1356 &mut tool_calls,
1357 &chat_delta(Some("call_1"), Some("search"), ""),
1358 );
1359 accumulate_tool_call_delta(&mut tool_calls, &chat_delta(Some(""), Some(""), "{\"q\":"));
1362 accumulate_tool_call_delta(
1363 &mut tool_calls,
1364 &chat_delta(Some(""), Some(""), "\"rust\"}"),
1365 );
1366
1367 let acc = &tool_calls[&0];
1368 assert_eq!(acc.id.as_deref(), Some("call_1"));
1369 assert_eq!(acc.name.as_deref(), Some("search"));
1370 assert_eq!(acc.arguments, "{\"q\":\"rust\"}");
1371 }
1372
1373 #[test]
1374 fn done_function_call_is_not_emitted_twice() {
1375 let mut function_calls = HashMap::new();
1376 let emitted = mark_and_emit_done_function_call(
1377 &mut function_calls,
1378 1,
1379 Some("item_1".to_string()),
1380 "item_1".to_string(),
1381 Some("call_1".to_string()),
1382 "search".to_string(),
1383 r#"{"q":"rust"}"#,
1384 );
1385 assert_eq!(emitted.id, "call_1");
1386 assert_eq!(emitted.name, "search");
1387 assert_eq!(drain_pending_function_calls(function_calls).len(), 0);
1388 }
1389
1390 #[test]
1391 fn pending_function_call_is_emitted_once_via_fallback() {
1392 let mut function_calls = HashMap::new();
1393 function_calls.insert(
1394 2,
1395 FunctionCallAccumulator {
1396 item_id: Some("item_2".to_string()),
1397 call_id: Some("call_2".to_string()),
1398 name: Some("lookup".to_string()),
1399 arguments: r#"{"id":42}"#.to_string(),
1400 emitted: false,
1401 },
1402 );
1403 let pending = drain_pending_function_calls(function_calls);
1404 assert_eq!(pending.len(), 1);
1405 assert_eq!(pending[0].id, "call_2");
1406 assert_eq!(pending[0].name, "lookup");
1407 }
1408
1409 #[test]
1410 fn empty_done_call_id_falls_back_to_item_id() {
1411 let mut function_calls = HashMap::new();
1412 let emitted = mark_and_emit_done_function_call(
1413 &mut function_calls,
1414 3,
1415 Some("item_3".to_string()),
1416 "item_3".to_string(),
1417 Some(String::new()),
1418 "search".to_string(),
1419 r#"{"q":"terminal first"}"#,
1420 );
1421 assert_eq!(emitted.id, "item_3");
1422 }
1423
1424 #[test]
1425 fn empty_pending_call_id_falls_back_to_item_id() {
1426 let mut function_calls = HashMap::new();
1427 function_calls.insert(
1428 4,
1429 FunctionCallAccumulator {
1430 item_id: Some("item_4".to_string()),
1431 call_id: Some(" ".to_string()),
1432 name: Some("lookup".to_string()),
1433 arguments: r#"{"id":7}"#.to_string(),
1434 emitted: false,
1435 },
1436 );
1437 let pending = drain_pending_function_calls(function_calls);
1438 assert_eq!(pending.len(), 1);
1439 assert_eq!(pending[0].id, "item_4");
1440 assert_eq!(pending[0].name, "lookup");
1441 }
1442
1443 #[test]
1444 fn empty_stream_identifiers_fall_back_to_output_index() {
1445 let mut function_calls = HashMap::new();
1446 function_calls.insert(
1447 12,
1448 FunctionCallAccumulator {
1449 item_id: None,
1450 call_id: None,
1451 name: None,
1452 arguments: r#"{"q":"may"}"#.to_string(),
1453 emitted: false,
1454 },
1455 );
1456 let emitted = mark_and_emit_done_function_call(
1457 &mut function_calls,
1458 12,
1459 None,
1460 String::new(),
1461 Some(String::new()),
1462 "search".to_string(),
1463 r#"{"q":"may"}"#,
1464 );
1465 assert_eq!(emitted.id, "response_output_12");
1466 }
1467
1468 #[test]
1469 fn serialized_responses_request_rejects_empty_call_id() {
1470 let snapshot = ParameterSnapshot::from(&aither_core::llm::model::Parameters::default());
1471 let request = ResponsesRequest::new(
1472 "gpt-5.4".to_string(),
1473 vec![ResponsesInputItem::FunctionCall {
1474 kind: "function_call",
1475 call_id: String::new(),
1476 name: "search".to_string(),
1477 arguments: "{}".to_string(),
1478 }],
1479 &snapshot,
1480 None,
1481 responses_tool_choice(&snapshot, false),
1482 false,
1483 );
1484
1485 let error = validate_serialized_responses_request(&request)
1486 .expect_err("empty serialized call_id must fail");
1487 assert!(
1488 error.to_string().contains("input[0]"),
1489 "unexpected error: {error}"
1490 );
1491 }
1492
1493 #[test]
1494 fn serialized_responses_request_rejects_whitespace_call_id() {
1495 let snapshot = ParameterSnapshot::from(&aither_core::llm::model::Parameters::default());
1496 let request = ResponsesRequest::new(
1497 "gpt-5.4".to_string(),
1498 vec![ResponsesInputItem::FunctionCallOutput {
1499 kind: "function_call_output",
1500 call_id: " ".to_string(),
1501 output: "{}".to_string(),
1502 }],
1503 &snapshot,
1504 None,
1505 responses_tool_choice(&snapshot, false),
1506 false,
1507 );
1508
1509 let error = validate_serialized_responses_request(&request)
1510 .expect_err("whitespace serialized call_id must fail");
1511 assert!(
1512 error.to_string().contains("blank call_id"),
1513 "unexpected error: {error}"
1514 );
1515 }
1516
1517 #[tokio::test]
1518 async fn configured_timeout_is_enforced_for_sse_requests() {
1519 let timeout = request_with_timeout(
1520 Duration::from_millis(20),
1521 futures_lite::future::pending::<Result<(), zenwave::Error>>(),
1522 )
1523 .await;
1524 assert!(matches!(timeout, Err(OpenAIError::Timeout)));
1525 }
1526
1527 #[test]
1528 fn chat_usage_mapping_keeps_reasoning_and_cache_tokens() {
1529 let usage = ChatCompletionUsage {
1530 prompt_tokens: Some(11),
1531 completion_tokens: Some(7),
1532 total_tokens: Some(18),
1533 prompt_tokens_details: Some(ChatPromptTokensDetails {
1534 cached_tokens: Some(3),
1535 }),
1536 completion_tokens_details: Some(ChatCompletionTokensDetails {
1537 reasoning_tokens: Some(2),
1538 }),
1539 };
1540 let mapped = usage_from_chat_completion(&usage);
1541 assert_eq!(mapped.prompt_tokens, Some(11));
1542 assert_eq!(mapped.completion_tokens, Some(7));
1543 assert_eq!(mapped.total_tokens, Some(18));
1544 assert_eq!(mapped.reasoning_tokens, Some(2));
1545 assert_eq!(mapped.cache_read_tokens, Some(3));
1546 }
1547
1548 #[test]
1549 fn responses_usage_mapping_keeps_reasoning_and_cache_tokens() {
1550 let usage = ResponsesUsage {
1551 input_tokens: Some(19),
1552 output_tokens: Some(5),
1553 total_tokens: Some(24),
1554 input_token_details: Some(ResponsesInputTokenDetails {
1555 cached_tokens: Some(4),
1556 }),
1557 output_token_details: Some(ResponsesOutputTokenDetails {
1558 reasoning_tokens: Some(1),
1559 }),
1560 };
1561 let mapped = usage_from_responses(&usage);
1562 assert_eq!(mapped.prompt_tokens, Some(19));
1563 assert_eq!(mapped.completion_tokens, Some(5));
1564 assert_eq!(mapped.total_tokens, Some(24));
1565 assert_eq!(mapped.reasoning_tokens, Some(1));
1566 assert_eq!(mapped.cache_read_tokens, Some(4));
1567 }
1568}
1569
1570fn filter_tool_definitions(
1571 defs: Vec<aither_core::llm::tool::ToolDefinition>,
1572 choice: &ToolChoice,
1573) -> Vec<aither_core::llm::tool::ToolDefinition> {
1574 match choice {
1575 ToolChoice::None => Vec::new(),
1576 ToolChoice::Exact(name) => defs
1577 .into_iter()
1578 .filter(|tool| tool.name() == name)
1579 .collect(),
1580 ToolChoice::Auto | ToolChoice::Required => defs,
1581 }
1582}
1583
1584#[derive(Debug)]
1586pub struct Builder {
1587 api_key: String,
1588 base_url: String,
1589 api_kind: ApiKind,
1590 chat_model: String,
1591 embedding_model: String,
1592 embedding_dimensions: usize,
1593 image_model: String,
1594 audio_model: String,
1595 audio_voice: String,
1596 audio_format: String,
1597 transcription_model: String,
1598 moderation_model: String,
1599 legacy_max_tokens: bool,
1600 organization: Option<String>,
1601 native_abilities: Vec<Ability>,
1602 retry: RetryConfig,
1603 request_timeout: Duration,
1604 extra_headers: Vec<(String, String)>,
1605 store: Option<bool>,
1606}
1607
1608const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
1610
1611impl Builder {
1612 fn new(api_key: impl Into<String>) -> Self {
1613 Self {
1614 api_key: api_key.into(),
1615 base_url: DEFAULT_BASE_URL.to_string(),
1616 api_kind: ApiKind::Responses,
1617 chat_model: DEFAULT_MODEL.to_string(),
1618 embedding_model: DEFAULT_EMBEDDING_MODEL.to_string(),
1619 embedding_dimensions: DEFAULT_EMBEDDING_DIM,
1620 image_model: DEFAULT_IMAGE_MODEL.to_string(),
1621 audio_model: DEFAULT_AUDIO_MODEL.to_string(),
1622 audio_voice: DEFAULT_AUDIO_VOICE.to_string(),
1623 audio_format: DEFAULT_AUDIO_FORMAT.to_string(),
1624 transcription_model: DEFAULT_TRANSCRIPTION_MODEL.to_string(),
1625 moderation_model: DEFAULT_MODERATION_MODEL.to_string(),
1626 legacy_max_tokens: false,
1627 organization: None,
1628 native_abilities: Vec::new(),
1629 retry: RetryConfig::default(),
1630 request_timeout: DEFAULT_REQUEST_TIMEOUT,
1631 extra_headers: Vec::new(),
1632 store: None,
1633 }
1634 }
1635
1636 #[must_use]
1638 pub fn base_url(mut self, url: impl Into<String>) -> Self {
1639 self.base_url = url.into();
1640 self
1641 }
1642
1643 #[must_use]
1645 pub const fn use_responses_api(mut self) -> Self {
1646 self.api_kind = ApiKind::Responses;
1647 self
1648 }
1649
1650 #[must_use]
1652 pub const fn use_chat_completions_api(mut self) -> Self {
1653 self.api_kind = ApiKind::ChatCompletions;
1654 self
1655 }
1656
1657 #[must_use]
1661 pub const fn legacy_max_tokens(mut self, enabled: bool) -> Self {
1662 self.legacy_max_tokens = enabled;
1663 self
1664 }
1665
1666 #[must_use]
1668 pub fn model(mut self, model: impl Into<String>) -> Self {
1669 self.chat_model = sanitize_model(model);
1670 self
1671 }
1672
1673 #[must_use]
1675 pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
1676 let model = sanitize_model(model);
1677 if let Some(dim) = infer_embedding_dim(&model) {
1678 self.embedding_dimensions = dim;
1679 }
1680 self.embedding_model = model;
1681 self
1682 }
1683
1684 #[must_use]
1686 pub const fn embedding_dimensions(mut self, dimensions: usize) -> Self {
1687 self.embedding_dimensions = dimensions;
1688 self
1689 }
1690
1691 #[must_use]
1693 pub fn image_model(mut self, model: impl Into<String>) -> Self {
1694 self.image_model = sanitize_model(model);
1695 self
1696 }
1697
1698 #[must_use]
1700 pub fn audio_model(mut self, model: impl Into<String>) -> Self {
1701 self.audio_model = sanitize_model(model);
1702 self
1703 }
1704
1705 #[must_use]
1707 pub fn audio_voice(mut self, voice: impl Into<String>) -> Self {
1708 self.audio_voice = voice.into();
1709 self
1710 }
1711
1712 #[must_use]
1714 pub fn audio_format(mut self, format: impl Into<String>) -> Self {
1715 self.audio_format = format.into();
1716 self
1717 }
1718
1719 #[must_use]
1721 pub fn transcription_model(mut self, model: impl Into<String>) -> Self {
1722 self.transcription_model = sanitize_model(model);
1723 self
1724 }
1725
1726 #[must_use]
1728 pub fn moderation_model(mut self, model: impl Into<String>) -> Self {
1729 self.moderation_model = sanitize_model(model);
1730 self
1731 }
1732
1733 #[must_use]
1735 pub fn organization(mut self, organization: impl Into<String>) -> Self {
1736 self.organization = Some(organization.into());
1737 self
1738 }
1739
1740 #[must_use]
1746 pub fn extra_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1747 self.extra_headers.push((name.into(), value.into()));
1748 self
1749 }
1750
1751 #[must_use]
1757 pub const fn store(mut self, store: bool) -> Self {
1758 self.store = Some(store);
1759 self
1760 }
1761
1762 #[must_use]
1764 pub fn native_capabilities(mut self, abilities: impl IntoIterator<Item = Ability>) -> Self {
1765 for ability in abilities {
1766 if !self.native_abilities.contains(&ability) {
1767 self.native_abilities.push(ability);
1768 }
1769 }
1770 self
1771 }
1772
1773 #[must_use]
1775 pub fn enable_native_web_search(self) -> Self {
1776 self.native_capabilities([Ability::WebSearch])
1777 }
1778
1779 #[must_use]
1781 pub fn enable_native_pdf(self) -> Self {
1782 self.native_capabilities([Ability::Pdf])
1783 }
1784
1785 #[must_use]
1790 pub const fn retry(mut self, config: RetryConfig) -> Self {
1791 self.retry = config;
1792 self
1793 }
1794
1795 #[must_use]
1797 pub const fn max_retries(mut self, max_retries: u32) -> Self {
1798 self.retry.max_retries = max_retries;
1799 self
1800 }
1801
1802 #[must_use]
1804 pub fn no_retry(mut self) -> Self {
1805 self.retry = RetryConfig::none();
1806 self
1807 }
1808
1809 #[must_use]
1814 pub const fn timeout(mut self, timeout: Duration) -> Self {
1815 self.request_timeout = timeout;
1816 self
1817 }
1818
1819 #[must_use]
1821 pub fn build(self) -> OpenAI {
1822 OpenAI {
1823 inner: Arc::new(Config {
1824 api_key: self.api_key,
1825 base_url: self.base_url,
1826 api_kind: self.api_kind,
1827 chat_model: self.chat_model,
1828 embedding_model: self.embedding_model,
1829 embedding_dimensions: self.embedding_dimensions,
1830 image_model: self.image_model,
1831 audio_model: self.audio_model,
1832 audio_voice: self.audio_voice,
1833 audio_format: self.audio_format,
1834 transcription_model: self.transcription_model,
1835 moderation_model: self.moderation_model,
1836 legacy_max_tokens: self.legacy_max_tokens,
1837 organization: self.organization,
1838 native_abilities: self.native_abilities,
1839 retry: self.retry,
1840 request_timeout: self.request_timeout,
1841 extra_headers: self.extra_headers,
1842 store: self.store,
1843 }),
1844 }
1845 }
1846}
1847
1848#[derive(Debug, Clone)]
1849pub struct Config {
1850 pub(crate) api_key: String,
1851 pub(crate) base_url: String,
1852 pub(crate) api_kind: ApiKind,
1853 pub(crate) chat_model: String,
1854 pub(crate) embedding_model: String,
1855 pub(crate) embedding_dimensions: usize,
1856 pub(crate) image_model: String,
1857 pub(crate) audio_model: String,
1858 pub(crate) audio_voice: String,
1859 pub(crate) audio_format: String,
1860 pub(crate) transcription_model: String,
1861 pub(crate) moderation_model: String,
1862 pub(crate) legacy_max_tokens: bool,
1863 pub(crate) organization: Option<String>,
1864 pub(crate) native_abilities: Vec<Ability>,
1865 pub(crate) retry: RetryConfig,
1866 pub(crate) request_timeout: Duration,
1867 pub(crate) extra_headers: Vec<(String, String)>,
1868 pub(crate) store: Option<bool>,
1869}
1870
1871impl Config {
1872 pub(crate) fn request_url(&self, path: &str) -> String {
1873 format!(
1874 "{}/{}",
1875 self.base_url.trim_end_matches('/'),
1876 path.trim_start_matches('/')
1877 )
1878 }
1879
1880 pub(crate) fn request_auth(&self) -> String {
1881 format!("Bearer {}", self.api_key)
1882 }
1883}
1884
1885fn sanitize_model(model: impl Into<String>) -> String {
1886 model.into().trim().to_string()
1887}
1888
1889fn infer_embedding_dim(model: &str) -> Option<usize> {
1890 match model {
1891 "text-embedding-3-large" => Some(3072),
1892 "text-embedding-3-small" | "text-embedding-ada-002" => Some(1536),
1893 _ => None,
1894 }
1895}