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