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