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