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