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