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