1use std::collections::HashMap;
10use std::pin::Pin;
11use std::time::Instant;
12
13use futures::{Stream, StreamExt};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use tracing::Instrument;
17use ts_rs::TS;
18
19use crate::error::AiMuxError;
20use crate::language_model::LanguageModel;
21use crate::language_model_message::convert_to_language_model_prompt;
22use crate::message::{ModelMessage, ModelPrompt};
23use crate::options::{CallOptions, ResponseFormat, ToolChoice};
24use crate::parse_tool_call::{RawToolCall, ToolCallRepair, parse_tool_call};
25use crate::result::{
26 FilePart, GenerateContent, GenerateResult, ReasoningPart, SourcePart, StreamResult,
27 StreamTextResultAggregated,
28};
29use crate::stream_part::StreamPart;
30use crate::tool::Tool;
31use crate::types::{FinishReason, ReasoningEffort, Usage, Warning};
32use crate::{AbortSignal, retry, timeout};
33
34fn is_output_chunk(part: &StreamPart) -> bool {
37 match part {
38 StreamPart::TextDelta { delta, .. }
39 | StreamPart::ReasoningDelta { delta, .. }
40 | StreamPart::ToolInputDelta { delta, .. } => !delta.is_empty(),
41 StreamPart::ToolCall { .. } | StreamPart::File { .. } => true,
42 _ => false,
43 }
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
55#[ts(export)]
56pub struct GenerateTextOptions {
57 pub max_output_tokens: Option<u32>,
58 pub temperature: Option<f64>,
59 pub stop_sequences: Option<Vec<String>>,
60 pub top_p: Option<f64>,
61 pub top_k: Option<f64>,
62 pub presence_penalty: Option<f64>,
63 pub frequency_penalty: Option<f64>,
64 pub response_format: Option<ResponseFormat>,
65 #[ts(type = "number | null")]
66 pub seed: Option<u64>,
67 pub tools: Option<Vec<Tool>>,
68 pub tool_choice: Option<ToolChoice>,
69 pub headers: Option<HashMap<String, String>>,
70 pub provider_options: Option<HashMap<String, Value>>,
71 pub reasoning: Option<ReasoningEffort>,
73 pub instructions: Option<String>,
75 pub body_overrides: Option<Value>,
77 pub max_retries: Option<u32>,
79 pub timeout: Option<crate::options::TimeoutConfiguration>,
81 #[serde(skip_serializing_if = "Option::is_none")]
86 pub session_id: Option<String>,
87 #[serde(skip)]
92 #[ts(skip)]
93 pub abort_signal: Option<AbortSignal>,
94 pub include_raw_chunks: Option<bool>,
96 #[serde(skip)]
99 #[ts(skip)]
100 pub repair_tool_call: Option<ToolCallRepair>,
101}
102
103impl GenerateTextOptions {
104 #[must_use]
106 pub fn into_call_options(
107 self,
108 prompt: crate::language_model_message::LanguageModelPrompt,
109 ) -> CallOptions {
110 CallOptions {
111 prompt,
112 max_output_tokens: self.max_output_tokens,
113 temperature: self.temperature,
114 stop_sequences: self.stop_sequences,
115 top_p: self.top_p,
116 top_k: self.top_k,
117 presence_penalty: self.presence_penalty,
118 frequency_penalty: self.frequency_penalty,
119 response_format: self.response_format,
120 seed: self.seed,
121 tools: self.tools,
122 tool_choice: self.tool_choice.unwrap_or_default(),
123 headers: self.headers,
124 provider_options: self.provider_options,
125 reasoning: self.reasoning,
126 body_overrides: self.body_overrides,
127 max_retries: self.max_retries,
128 timeout: self.timeout,
129 session_id: self.session_id,
130 abort_signal: self.abort_signal,
131 call_id: None,
132 recording_context: None,
133 include_raw_chunks: self.include_raw_chunks,
134 }
135 }
136}
137
138#[derive(Debug, Serialize, Deserialize, TS)]
144#[ts(export)]
145pub struct GenerateTextResult {
146 pub text: String,
148 pub tool_calls: Vec<crate::tool::ToolCall>,
150 pub finish_reason: FinishReason,
152 pub usage: Usage,
154 pub warnings: Vec<Warning>,
156 pub raw: GenerateResult,
158 #[serde(default)]
161 pub reasoning: Vec<ReasoningPart>,
162 #[serde(default)]
164 pub reasoning_text: String,
165 #[serde(default)]
167 pub sources: Vec<SourcePart>,
168 #[serde(default)]
170 pub files: Vec<FilePart>,
171 #[serde(default)]
174 pub response_messages: Vec<ModelMessage>,
175 #[serde(default)]
178 pub raw_finish_reason: Option<String>,
179 #[serde(default)]
182 pub provider_metadata: Option<Value>,
183 #[serde(default)]
186 pub response: crate::types::ResponseMetadata,
187 #[serde(default)]
190 pub total_usage: Usage,
191}
192
193#[derive(Debug, Serialize, Deserialize, TS)]
196#[ts(export)]
197pub struct GenerateObjectResult {
198 pub object: Value,
200 pub finish_reason: FinishReason,
202 #[serde(default)]
204 pub raw_finish_reason: Option<String>,
205 pub usage: Usage,
207 pub warnings: Vec<Warning>,
209 #[serde(default)]
211 pub reasoning: Option<String>,
212 #[serde(default)]
214 pub provider_metadata: Option<Value>,
215 #[serde(default)]
217 pub response: crate::types::ResponseMetadata,
218 pub raw: GenerateTextResult,
220}
221
222pub struct StreamTextResult {
224 pub stream: Pin<Box<dyn Stream<Item = Result<StreamPart, AiMuxError>> + Send>>,
226 pub request_body: Option<serde_json::Value>,
228 pub response_headers: Option<HashMap<String, String>>,
230}
231
232impl std::fmt::Debug for StreamTextResult {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.debug_struct("StreamTextResult")
235 .field("stream", &"<stream>")
236 .field("request_body", &self.request_body)
237 .field("response_headers", &self.response_headers)
238 .finish()
239 }
240}
241
242impl StreamTextResult {
243 pub async fn text(self) -> Result<String, AiMuxError> {
250 let mut result = String::new();
251 let mut saw_output = false;
252 let mut saw_finish = false;
253 let mut provider_error: Option<AiMuxError> = None;
254 let mut stream = self.stream;
255 while let Some(part) = stream.next().await {
256 let part = match part {
257 Ok(part) => part,
258 Err(error) if error.is_recoverable_stream_error() => {
263 if provider_error.is_none() {
264 provider_error = Some(error);
265 }
266 continue;
267 }
268 Err(error) => return Err(error),
269 };
270 if is_output_chunk(&part) {
271 saw_output = true;
272 }
273 match part {
274 StreamPart::TextDelta { delta, .. } => result.push_str(&delta),
275 StreamPart::Finish { .. } => {
276 saw_finish = true;
277 break;
278 }
279 StreamPart::Error { error } if provider_error.is_none() => {
283 provider_error = Some(error);
284 }
285 _ => {}
286 }
287 }
288 if let Some(error) = provider_error {
289 return Err(error);
290 }
291 if !saw_finish && !saw_output {
295 return Err(AiMuxError::InvalidResponseData(
296 "No output generated. The model stream ended without a finish chunk.".into(),
297 ));
298 }
299 Ok(result)
300 }
301
302 pub async fn consume(self) -> Result<StreamTextResultAggregated, AiMuxError> {
313 let mut text = String::new();
314 let mut tool_calls: Vec<crate::tool::ToolCall> = Vec::new();
315 let mut sources: Vec<SourcePart> = Vec::new();
316 let mut files: Vec<FilePart> = Vec::new();
317 let mut warnings: Vec<Warning> = Vec::new();
318 let mut finish_reason = FinishReason {
319 unified: crate::types::FinishReasonUnified::Stop,
320 raw: None,
321 };
322 let mut raw_finish_reason: Option<String> = None;
323 let mut usage = Usage::default();
324 let mut finish_provider_metadata: Option<Value> = None;
325 let mut response: Option<crate::types::ResponseMetadata> = None;
326 let mut rm = crate::response_messages::ResponseMessageBuilder::new();
327
328 let mut saw_output = false;
329 let mut saw_finish = false;
330 let mut provider_error: Option<AiMuxError> = None;
331 let mut stream = self.stream;
332 while let Some(part) = stream.next().await {
333 let part = match part {
334 Ok(part) => part,
335 Err(error) if error.is_recoverable_stream_error() => {
340 if provider_error.is_none() {
341 provider_error = Some(error);
342 }
343 continue;
344 }
345 Err(error) => return Err(error),
346 };
347 if is_output_chunk(&part) {
348 saw_output = true;
349 }
350 match part {
351 StreamPart::TextStart {
352 provider_metadata, ..
353 } => rm.text_start(provider_metadata),
354 StreamPart::TextDelta {
355 delta,
356 provider_metadata,
357 ..
358 } => {
359 text.push_str(&delta);
360 rm.text_delta(&delta, provider_metadata);
361 }
362 StreamPart::TextEnd {
363 provider_metadata, ..
364 } => rm.text_end(provider_metadata),
365 StreamPart::ReasoningStart {
366 provider_metadata, ..
367 } => rm.reasoning_start(provider_metadata),
368 StreamPart::ReasoningDelta {
369 delta,
370 provider_metadata,
371 ..
372 } => rm.reasoning_delta(&delta, provider_metadata),
373 StreamPart::ReasoningEnd {
374 provider_metadata, ..
375 } => rm.reasoning_end(provider_metadata),
376 StreamPart::ToolCall {
377 tool_call_id,
378 tool_name,
379 input,
380 provider_executed,
381 dynamic,
382 thought_signature,
383 provider_metadata,
384 invalid,
385 error,
386 ..
387 } => {
388 let call = crate::tool::ToolCall {
389 tool_call_id,
390 tool_name,
391 input,
392 provider_executed,
393 dynamic,
394 thought_signature,
395 provider_metadata,
396 invalid,
397 error,
398 };
399 rm.tool_call(&call);
400 tool_calls.push(call);
401 }
402 StreamPart::ToolResult {
403 tool_call_id,
404 tool_name,
405 result,
406 is_error,
407 preliminary,
408 dynamic,
409 provider_metadata,
410 } => rm.tool_result(
411 tool_call_id,
412 tool_name,
413 result,
414 is_error,
415 preliminary,
416 dynamic,
417 provider_metadata,
418 ),
419 StreamPart::Source {
420 id,
421 source_type,
422 url,
423 title,
424 ..
425 } => {
426 sources.push(SourcePart {
427 id,
428 source_type,
429 url,
430 title,
431 });
432 }
433 StreamPart::File {
434 data, media_type, ..
435 } => {
436 files.push(FilePart { data, media_type });
437 }
438 StreamPart::StreamStart { warnings: w, .. } => {
439 warnings = w;
440 }
441 StreamPart::Finish {
442 finish_reason: fr,
443 usage: u,
444 provider_metadata: pm,
445 } => {
446 raw_finish_reason = fr.raw.clone();
447 finish_reason = fr;
448 usage = u.clone();
449 finish_provider_metadata = pm;
450 saw_finish = true;
451 break;
452 }
453 StreamPart::Error { error } if provider_error.is_none() => {
457 provider_error = Some(error);
458 }
459 StreamPart::ResponseMetadata {
460 id,
461 timestamp,
462 model_id,
463 } => {
464 response = Some(crate::types::ResponseMetadata {
465 id,
466 timestamp,
467 model_id,
468 });
469 }
470 _ => {}
471 }
472 }
473
474 if let Some(error) = provider_error {
475 return Err(error);
476 }
477 if !saw_finish {
478 if !saw_output {
482 return Err(AiMuxError::InvalidResponseData(
483 "No output generated. The model stream ended without a finish chunk.".into(),
484 ));
485 }
486 finish_reason = FinishReason {
487 unified: crate::types::FinishReasonUnified::Other,
488 raw: None,
489 };
490 }
491
492 let crate::response_messages::ResponseMessages {
493 messages: response_messages,
494 reasoning,
495 } = rm.finish();
496
497 let reasoning_text = reasoning
498 .iter()
499 .map(|r| r.text.as_str())
500 .collect::<Vec<_>>()
501 .join("");
502
503 Ok(StreamTextResultAggregated {
504 text,
505 reasoning,
506 reasoning_text,
507 tool_calls,
508 sources,
509 files,
510 finish_reason,
511 raw_finish_reason,
512 total_usage: usage.clone(),
513 usage,
514 warnings,
515 provider_metadata: finish_provider_metadata,
516 response,
517 response_messages,
518 })
519 }
520}
521
522pub async fn generate_text(
550 model: &dyn LanguageModel,
551 prompt: impl Into<ModelPrompt>,
552 options: GenerateTextOptions,
553) -> Result<GenerateTextResult, AiMuxError> {
554 let repair_tool_call = options.repair_tool_call.clone();
556 let tools = options.tools.clone();
557 let operation_instructions = options.instructions.clone();
558 let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref());
559 let lm_prompt = convert_to_language_model_prompt(&messages, instructions);
560
561 let mut call_options = options.into_call_options(lm_prompt);
563 let operation_timeout =
564 timeout::OperationTimeout::new(call_options.timeout.unwrap_or_default())?;
565 let abort_signal = call_options.abort_signal.clone();
566 let retries = retry::prepare_retries(
567 call_options.max_retries,
568 model.retry_config(),
569 abort_signal.clone(),
570 );
571
572 let context = crate::recording::recorder().map(|recorder| {
576 let call_id = crate::recording::new_call_id();
577 call_options.call_id = Some(call_id.clone());
578 let ctx = crate::recording::RecordingContext::new(call_id.clone(), recorder);
579 call_options.recording_context = Some(ctx.clone());
580 ctx.recorder.record_input(
581 &ctx.call_id,
582 &call_options,
583 model.provider(),
584 model.model_id(),
585 );
586 ctx.recorder
587 .record_provider(&ctx.call_id, &model.config_snapshot());
588 ctx.recorder.record_transport_closed(&ctx.call_id);
592 ctx
593 });
594 let call_id = context.as_ref().map(|c| c.call_id.clone());
595 let recorder = context.as_ref().map(|c| c.recorder.clone());
596
597 if let (Some((session_id, source)), Some(store)) = (
604 crate::session::resolve_session_id(
605 call_options.session_id.as_deref(),
606 &call_options.prompt,
607 ),
608 crate::session::session_store(),
609 ) {
610 let call = store.append(&session_id, call_options.call_id.as_deref(), source);
611 if let (Some(recorder), Some(call_id)) = (&recorder, &call_id) {
614 recorder.record_session(call_id, &session_id, call.step);
615 }
616 }
617
618 let started = Instant::now();
621 let span = tracing::info_span!(
622 target: "aimux_core::generate",
623 "generate",
624 provider = %model.provider(),
625 model = %model.model_id(),
626 modality = "text",
627 );
628 let operation = retries.retry(|| {
629 if let Some(context) = &context {
630 let _ = context.start_attempt();
631 }
632 do_generate_with_logging(model, &call_options, span.clone(), started)
633 });
634 let result = match timeout::run(operation, abort_signal.as_ref(), operation_timeout).await {
635 Ok(r) => r,
636 Err(e) => {
637 if let (Some(rec), Some(call_id)) = (&recorder, &call_id) {
638 rec.record_outcome(call_id, &crate::recording::OutcomeRecord::from_error(&e));
639 }
640 return Err(e);
641 }
642 };
643 let mut text = String::new();
645 let mut tool_calls = Vec::new();
646 let mut sources = Vec::new();
647 let mut files = Vec::new();
648 let mut rm = crate::response_messages::ResponseMessageBuilder::new();
650 for content in &result.content {
651 match content {
652 GenerateContent::Text {
653 text: t,
654 provider_metadata,
655 } => {
656 text.push_str(t);
657 rm.text(t, provider_metadata.as_ref());
658 }
659 GenerateContent::ToolCall {
660 tool_call_id,
661 tool_name,
662 input,
663 provider_executed,
664 dynamic,
665 thought_signature,
666 provider_metadata,
667 ..
668 } => {
669 let parsed = parse_tool_call(
670 RawToolCall {
671 tool_call_id: tool_call_id.clone(),
672 tool_name: tool_name.clone(),
673 input: input.clone(),
674 provider_executed: *provider_executed,
675 dynamic: *dynamic,
676 thought_signature: thought_signature.clone(),
677 provider_metadata: provider_metadata.clone(),
678 },
679 tools.as_deref(),
680 repair_tool_call.as_ref(),
681 &messages,
682 operation_instructions.as_deref(),
683 )
684 .await;
685 rm.tool_call(&parsed);
686 tool_calls.push(parsed);
687 }
688 GenerateContent::Reasoning {
689 text: rtext,
690 provider_metadata,
691 } => {
692 rm.reasoning(rtext, provider_metadata.as_ref());
693 }
694 GenerateContent::Source {
695 id,
696 source_type,
697 url,
698 title,
699 ..
700 } => {
701 sources.push(SourcePart {
702 id: id.clone(),
703 source_type: source_type.clone(),
704 url: url.clone(),
705 title: title.clone(),
706 });
707 }
708 GenerateContent::File {
709 data, media_type, ..
710 } => {
711 files.push(FilePart {
712 data: data.clone(),
713 media_type: media_type.clone(),
714 });
715 }
716 GenerateContent::ToolResult {
719 tool_call_id,
720 tool_name,
721 result,
722 is_error,
723 preliminary,
724 dynamic,
725 provider_metadata,
726 } => {
727 rm.tool_result(
728 tool_call_id.clone(),
729 tool_name.clone(),
730 result.clone(),
731 *is_error,
732 *preliminary,
733 *dynamic,
734 provider_metadata.clone(),
735 );
736 }
737 }
738 }
739
740 let crate::response_messages::ResponseMessages {
741 messages: response_messages,
742 reasoning,
743 } = rm.finish();
744 let reasoning_text = reasoning
745 .iter()
746 .map(|r| r.text.as_str())
747 .collect::<Vec<_>>()
748 .join("");
749
750 if let (Some(rec), Some(call_id)) = (&recorder, &call_id) {
754 rec.record_outcome(
755 call_id,
756 &crate::recording::OutcomeRecord::from_generate_result(&result),
757 );
758 }
759
760 let raw_finish_reason = result.finish_reason.raw.clone();
762 let provider_metadata = result.provider_metadata.clone();
763 let response = result.response.clone();
764 let usage = result.usage.clone();
765
766 Ok(GenerateTextResult {
767 text,
768 tool_calls,
769 finish_reason: result.finish_reason.clone(),
770 usage: usage.clone(),
771 warnings: result.warnings.clone(),
772 raw: result,
773 reasoning,
774 reasoning_text,
775 sources,
776 files,
777 response_messages,
778 raw_finish_reason,
779 provider_metadata,
780 response,
781 total_usage: usage,
782 })
783}
784
785pub async fn generate_object(
825 model: &dyn LanguageModel,
826 prompt: impl Into<ModelPrompt>,
827 options: GenerateTextOptions,
828) -> Result<GenerateObjectResult, AiMuxError> {
829 let text_result = generate_text(model, prompt, options).await?;
830 let repaired = crate::json_repair::fix_json(&text_result.text);
831 let object: Value = serde_json::from_str(&repaired).map_err(|e| {
832 AiMuxError::JsonParse(format!(
833 "generateObject: model output is not valid JSON after repair: {e}"
834 ))
835 })?;
836 let raw_finish_reason = text_result.raw_finish_reason.clone();
837 let finish_reason = text_result.finish_reason.clone();
838 let usage = text_result.usage.clone();
839 let warnings = text_result.warnings.clone();
840 let reasoning = if text_result.reasoning_text.is_empty() {
841 None
842 } else {
843 Some(text_result.reasoning_text.clone())
844 };
845 let provider_metadata = text_result.raw.provider_metadata.clone();
846 let response = text_result.raw.response.clone();
847 Ok(GenerateObjectResult {
848 object,
849 finish_reason,
850 raw_finish_reason,
851 usage,
852 warnings,
853 reasoning,
854 provider_metadata,
855 response,
856 raw: text_result,
857 })
858}
859
860pub async fn stream_text(
894 model: &dyn LanguageModel,
895 prompt: impl Into<ModelPrompt>,
896 options: GenerateTextOptions,
897) -> Result<StreamTextResult, AiMuxError> {
898 let repair_tool_call = options.repair_tool_call.clone();
900 let tools = options.tools.clone();
901 let operation_instructions = options.instructions.clone();
902 let (messages, instructions) = split_prompt(prompt.into(), operation_instructions.as_deref());
903 let lm_prompt = convert_to_language_model_prompt(&messages, instructions);
904
905 let mut call_options = options.into_call_options(lm_prompt);
907 let stream_timeout = call_options.timeout.unwrap_or_default();
908 let operation_timeout = timeout::OperationTimeout::new(stream_timeout)?;
909 let first_chunk_deadline = stream_timeout
914 .first_chunk_ms
915 .map(|duration_ms| timeout::TimeoutDeadline::from_now("First chunk", duration_ms))
916 .transpose()?;
917 let abort_signal = call_options.abort_signal.clone();
918 let retries = retry::prepare_retries(
919 call_options.max_retries,
920 model.retry_config(),
921 abort_signal.clone(),
922 );
923
924 let context = crate::recording::recorder().map(|recorder| {
928 let call_id = crate::recording::new_call_id();
929 call_options.call_id = Some(call_id.clone());
930 let ctx = crate::recording::RecordingContext::new(call_id.clone(), recorder);
931 call_options.recording_context = Some(ctx.clone());
932 ctx.recorder.record_input(
933 &ctx.call_id,
934 &call_options,
935 model.provider(),
936 model.model_id(),
937 );
938 ctx.recorder
939 .record_provider(&ctx.call_id, &model.config_snapshot());
940 ctx.recorder.record_transport_closed(&ctx.call_id);
944 ctx
945 });
946 let call_id = context.as_ref().map(|c| c.call_id.clone());
947 let recorder = context.as_ref().map(|c| c.recorder.clone());
948
949 if let (Some((session_id, source)), Some(store)) = (
956 crate::session::resolve_session_id(
957 call_options.session_id.as_deref(),
958 &call_options.prompt,
959 ),
960 crate::session::session_store(),
961 ) {
962 let call = store.append(&session_id, call_options.call_id.as_deref(), source);
963 if let (Some(recorder), Some(call_id)) = (&recorder, &call_id) {
966 recorder.record_session(call_id, &session_id, call.step);
967 }
968 }
969
970 let span = tracing::info_span!(
972 target: "aimux_core::generate",
973 "generate",
974 provider = %model.provider(),
975 model = %model.model_id(),
976 modality = "text",
977 );
978 let operation = retries.retry(|| {
979 if let Some(context) = &context {
980 let _ = context.start_attempt();
981 }
982 model.do_stream(&call_options).instrument(span.clone())
986 });
987 let setup_deadline = timeout::min_deadline(operation_timeout.deadline(), first_chunk_deadline);
988 let result: StreamResult =
989 match timeout::run_until(operation, abort_signal.as_ref(), setup_deadline).await {
990 Ok(r) => r,
991 Err(e) => {
992 if let (Some(rec), Some(call_id)) = (&recorder, &call_id) {
993 rec.record_outcome(call_id, &crate::recording::OutcomeRecord::from_error(&e));
994 }
995 return Err(e);
996 }
997 };
998 let StreamResult {
1000 stream,
1001 request_body,
1002 response_headers,
1003 } = result;
1004
1005 let operation_deadline = operation_timeout.deadline();
1009 let stream: Pin<Box<dyn Stream<Item = Result<StreamPart, AiMuxError>> + Send>> = {
1022 let mut stream = stream;
1023 let chunk_ms = stream_timeout.chunk_ms;
1024 let pump_abort = abort_signal.clone();
1025 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1026 let pump = tokio::spawn(async move {
1027 let mut chunk_deadline = first_chunk_deadline;
1028
1029 loop {
1030 let next = tokio::select! {
1031 biased;
1032 () = timeout::wait_for_abort(pump_abort.as_ref()) => break,
1035 () = timeout::wait_for_deadline(operation_deadline) => {
1036 let _ = tx.send(Err(operation_deadline
1037 .expect("operation deadline future only resolves for a deadline")
1038 .error()));
1039 break;
1040 }
1041 () = timeout::wait_for_deadline(chunk_deadline) => {
1042 let _ = tx.send(Err(chunk_deadline
1043 .expect("chunk deadline future only resolves for a deadline")
1044 .error()));
1045 break;
1046 }
1047 item = stream.next() => item,
1048 };
1049
1050 let Some(item) = next else { break };
1051 let terminal = is_terminal_item(&item);
1052 if let Ok(part) = &item
1053 && is_output_chunk(part)
1054 {
1055 chunk_deadline = match chunk_ms
1056 .map(|duration_ms| timeout::TimeoutDeadline::from_now("Chunk", duration_ms))
1057 .transpose()
1058 {
1059 Ok(deadline) => deadline,
1060 Err(error) => {
1061 let _ = tx.send(Err(error));
1062 break;
1063 }
1064 };
1065 }
1066
1067 if tx.send(item).is_err() || terminal {
1069 break;
1070 }
1071 }
1072 });
1073 let pump = AbortOnDrop(pump);
1074 Box::pin(async_stream::stream! {
1075 let _pump = pump;
1076 loop {
1077 let next = tokio::select! {
1078 biased;
1079 () = timeout::wait_for_abort(abort_signal.as_ref()) => {
1080 yield Err(AiMuxError::from_abort_signal(
1081 abort_signal.as_ref().expect("abort future only resolves for a signal"),
1082 ));
1083 break;
1084 }
1085 item = rx.recv() => item,
1086 };
1087 let Some(item) = next else { break };
1088 let terminal = is_terminal_item(&item);
1092 yield item;
1093 if terminal {
1094 break;
1095 }
1096 }
1097 })
1098 };
1099 let mut stream = stream;
1100 let stream: Pin<Box<dyn Stream<Item = Result<StreamPart, AiMuxError>> + Send>> =
1101 Box::pin(async_stream::stream! {
1102 while let Some(item) = stream.next().await {
1103 match item {
1104 Ok(StreamPart::ToolCall {
1105 tool_call_id,
1106 tool_name,
1107 input,
1108 provider_executed,
1109 dynamic,
1110 thought_signature,
1111 provider_metadata,
1112 ..
1113 }) => {
1114 let parsed = parse_tool_call(
1115 RawToolCall {
1116 tool_call_id,
1117 tool_name,
1118 input: crate::parse_tool_call::raw_tool_input(&input),
1119 provider_executed,
1120 dynamic,
1121 thought_signature,
1122 provider_metadata,
1123 },
1124 tools.as_deref(),
1125 repair_tool_call.as_ref(),
1126 &messages,
1127 operation_instructions.as_deref(),
1128 ).await;
1129 yield Ok(StreamPart::ToolCall {
1130 tool_call_id: parsed.tool_call_id,
1131 tool_name: parsed.tool_name,
1132 input: parsed.input,
1133 provider_executed: parsed.provider_executed,
1134 dynamic: parsed.dynamic,
1135 thought_signature: parsed.thought_signature,
1136 invalid: parsed.invalid,
1137 error: parsed.error,
1138 provider_metadata: parsed.provider_metadata,
1139 });
1140 }
1141 item => yield item,
1142 }
1143 }
1144 });
1145 let stream = crate::recording::RecordingOutcomeStream::new(
1147 stream,
1148 recorder.clone(),
1149 call_id.unwrap_or_default(),
1150 );
1151
1152 Ok(StreamTextResult {
1155 stream: Box::pin(stream),
1156 request_body,
1157 response_headers,
1158 })
1159}
1160
1161fn is_terminal_item(item: &Result<StreamPart, AiMuxError>) -> bool {
1165 matches!(item, Ok(StreamPart::Finish { .. }))
1166 || matches!(item, Err(error) if !error.is_recoverable_stream_error())
1167}
1168
1169struct AbortOnDrop(tokio::task::JoinHandle<()>);
1172
1173impl Drop for AbortOnDrop {
1174 fn drop(&mut self) {
1175 self.0.abort();
1176 }
1177}
1178async fn do_generate_with_logging(
1182 model: &dyn LanguageModel,
1183 call_options: &CallOptions,
1184 span: tracing::Span,
1185 started: Instant,
1186) -> Result<GenerateResult, AiMuxError> {
1187 let r = async { model.do_generate(call_options).await }
1188 .instrument(span)
1189 .await?;
1190 tracing::info!(
1191 target: "aimux_core::generate",
1192 ok = true,
1193 duration_ms = started.elapsed().as_millis() as u64,
1194 finish_reason = ?r.finish_reason.unified,
1195 "generate_end"
1196 );
1197 Ok(r)
1198}
1199
1200pub(crate) fn split_prompt(
1207 prompt: ModelPrompt,
1208 instructions: Option<&str>,
1209) -> (Vec<ModelMessage>, Option<&str>) {
1210 match prompt {
1211 ModelPrompt::Text(text) => (vec![ModelMessage::user(text)], instructions),
1212 ModelPrompt::Messages(msgs) => (msgs, instructions),
1213 }
1214}
1215
1216use crate::openai_output::{
1221 ChatCompletion, ChatCompletionFunction, ChatCompletionStream, ChatCompletionToolCall,
1222 OpenAiStreamOptions, parsed_tool_call_arguments, to_chat_completion, to_chat_completion_stream,
1223};
1224
1225pub async fn generate_text_as_openai(
1258 model: &dyn LanguageModel,
1259 prompt: impl Into<ModelPrompt>,
1260 options: GenerateTextOptions,
1261) -> Result<ChatCompletion, AiMuxError> {
1262 let result = generate_text(model, prompt, options).await?;
1263 Ok(generate_text_result_to_chat_completion(
1264 &result,
1265 model.model_id(),
1266 ))
1267}
1268
1269#[must_use]
1280pub fn generate_text_result_to_chat_completion(
1281 result: &GenerateTextResult,
1282 model_id: &str,
1283) -> ChatCompletion {
1284 let mut completion = to_chat_completion(&result.raw, model_id);
1285 if let Some(choice) = completion.choices.first_mut() {
1286 choice.message.tool_calls = if result.tool_calls.is_empty() {
1287 None
1288 } else {
1289 Some(
1290 result
1291 .tool_calls
1292 .iter()
1293 .map(|tool_call| ChatCompletionToolCall {
1294 id: tool_call.tool_call_id.clone(),
1295 tool_type: "function".to_string(),
1296 function: ChatCompletionFunction {
1297 name: tool_call.tool_name.clone(),
1298 arguments: parsed_tool_call_arguments(
1299 &tool_call.input,
1300 tool_call.invalid,
1301 tool_call.error.as_ref(),
1302 ),
1303 },
1304 })
1305 .collect(),
1306 )
1307 };
1308 }
1309 completion
1310}
1311
1312pub async fn stream_text_as_openai(
1357 model: &dyn LanguageModel,
1358 prompt: impl Into<ModelPrompt>,
1359 options: GenerateTextOptions,
1360 stream_options: OpenAiStreamOptions,
1361) -> Result<ChatCompletionStream, AiMuxError> {
1362 let result = stream_text(model, prompt, options).await?;
1363 Ok(to_chat_completion_stream(
1364 result.stream,
1365 model.model_id(),
1366 stream_options,
1367 ))
1368}
1369
1370#[cfg(test)]
1371mod operation_retry_tests {
1372 use std::sync::atomic::{AtomicUsize, Ordering};
1373
1374 use async_trait::async_trait;
1375
1376 use super::*;
1377 use crate::{ApiCallError, LanguageModel, prelude::StreamPart};
1378
1379 enum StreamBehavior {
1380 Normal,
1381 FinishThenPending,
1382 ProviderErrorThenFinish,
1383 ParseErrorThenEvent,
1384 TransportErrorThenPending,
1385 NeverReturns,
1386 RetryableFirstError,
1387 NonRetryableFirstError,
1388 }
1389
1390 struct StreamModel {
1391 behavior: StreamBehavior,
1392 calls: AtomicUsize,
1393 }
1394
1395 impl StreamModel {
1396 fn new(behavior: StreamBehavior) -> Self {
1397 Self {
1398 behavior,
1399 calls: AtomicUsize::new(0),
1400 }
1401 }
1402
1403 fn api_error(status: u16, retryable: bool) -> AiMuxError {
1404 AiMuxError::ApiCall(Box::new(ApiCallError {
1405 status_code: Some(status),
1406 response_headers: Some(HashMap::from([("retry-after-ms".into(), "0".into())])),
1407 is_retryable: retryable,
1408 ..ApiCallError::new(
1409 "stream setup failed",
1410 "https://example.test/stream",
1411 serde_json::json!({}),
1412 )
1413 }))
1414 }
1415
1416 fn single_event_stream() -> StreamResult {
1417 StreamResult {
1418 stream: Box::pin(futures::stream::iter([Ok(StreamPart::TextDelta {
1419 id: "text-1".into(),
1420 delta: "hello".into(),
1421 provider_metadata: None,
1422 })])),
1423 request_body: None,
1424 response_headers: None,
1425 }
1426 }
1427
1428 fn finish_stream() -> StreamResult {
1429 let finish = StreamPart::Finish {
1430 finish_reason: crate::types::FinishReason {
1431 unified: crate::types::FinishReasonUnified::Stop,
1432 raw: Some("stop".into()),
1433 },
1434 usage: Usage::default(),
1435 provider_metadata: None,
1436 };
1437 StreamResult {
1438 stream: Box::pin(
1439 futures::stream::iter([Ok(finish)]).chain(futures::stream::pending()),
1440 ),
1441 request_body: None,
1442 response_headers: None,
1443 }
1444 }
1445
1446 fn provider_error_then_finish_stream() -> StreamResult {
1447 let finish = StreamPart::Finish {
1448 finish_reason: crate::types::FinishReason {
1449 unified: crate::types::FinishReasonUnified::Error,
1450 raw: None,
1451 },
1452 usage: Usage::default(),
1453 provider_metadata: None,
1454 };
1455 StreamResult {
1456 stream: Box::pin(
1457 futures::stream::iter([
1458 Ok(StreamPart::Error {
1459 error: AiMuxError::Other("provider error".into()),
1460 }),
1461 Ok(finish),
1462 ])
1463 .chain(futures::stream::pending()),
1464 ),
1465 request_body: None,
1466 response_headers: None,
1467 }
1468 }
1469
1470 fn parse_error_then_event_stream() -> StreamResult {
1471 StreamResult {
1472 stream: Box::pin(futures::stream::iter([
1473 Err(AiMuxError::JsonParse("malformed SSE data".into())),
1474 Ok(StreamPart::TextDelta {
1475 id: "text-1".into(),
1476 delta: "after-error".into(),
1477 provider_metadata: None,
1478 }),
1479 ])),
1480 request_body: None,
1481 response_headers: None,
1482 }
1483 }
1484 }
1485
1486 #[async_trait]
1487 impl LanguageModel for StreamModel {
1488 fn provider(&self) -> &str {
1489 "test"
1490 }
1491
1492 fn model_id(&self) -> &str {
1493 "stream-model"
1494 }
1495
1496 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
1497 Err(AiMuxError::Other("unused".into()))
1498 }
1499
1500 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
1501 let attempt = self.calls.fetch_add(1, Ordering::SeqCst);
1502 match self.behavior {
1503 StreamBehavior::Normal => Ok(Self::single_event_stream()),
1504 StreamBehavior::FinishThenPending => Ok(Self::finish_stream()),
1505 StreamBehavior::ProviderErrorThenFinish => {
1506 Ok(Self::provider_error_then_finish_stream())
1507 }
1508 StreamBehavior::ParseErrorThenEvent => Ok(Self::parse_error_then_event_stream()),
1509 StreamBehavior::TransportErrorThenPending => Ok(StreamResult {
1510 stream: Box::pin(
1511 futures::stream::iter([Err(AiMuxError::ApiCall(Box::new(ApiCallError {
1512 is_retryable: true,
1513 ..ApiCallError::new(
1514 "connection reset",
1515 "https://example.test/stream",
1516 serde_json::json!({}),
1517 )
1518 })))])
1519 .chain(futures::stream::pending()),
1520 ),
1521 request_body: None,
1522 response_headers: None,
1523 }),
1524 StreamBehavior::NeverReturns => std::future::pending().await,
1525 StreamBehavior::RetryableFirstError if attempt == 0 => {
1526 Err(Self::api_error(429, true))
1527 }
1528 StreamBehavior::RetryableFirstError => Ok(Self::single_event_stream()),
1529 StreamBehavior::NonRetryableFirstError => Err(Self::api_error(400, false)),
1530 }
1531 }
1532 }
1533
1534 fn retry_options() -> GenerateTextOptions {
1535 GenerateTextOptions {
1536 max_retries: Some(2),
1537 ..Default::default()
1538 }
1539 }
1540
1541 #[tokio::test]
1542 async fn stream_setup_preserves_the_peeked_first_event() {
1543 let model = StreamModel::new(StreamBehavior::Normal);
1544 let mut result = stream_text(&model, "hello", retry_options()).await.unwrap();
1545 let first = result.stream.next().await.unwrap().unwrap();
1546 assert!(matches!(first, StreamPart::TextDelta { delta, .. } if delta == "hello"));
1547 assert!(result.stream.next().await.is_none());
1548 }
1549
1550 #[tokio::test]
1551 async fn late_consumer_still_receives_a_first_chunk_delivered_on_time() {
1552 let model = StreamModel::new(StreamBehavior::Normal);
1557 let options = GenerateTextOptions {
1558 max_retries: Some(0),
1559 timeout: Some(crate::options::TimeoutConfiguration {
1560 first_chunk_ms: Some(1),
1561 ..Default::default()
1562 }),
1563 ..Default::default()
1564 };
1565 let mut result = stream_text(&model, "hello", options).await.unwrap();
1566
1567 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1568
1569 assert!(matches!(
1570 result.stream.next().await.unwrap().unwrap(),
1571 StreamPart::TextDelta { delta, .. } if delta == "hello"
1572 ));
1573 assert!(result.stream.next().await.is_none());
1574 }
1575
1576 #[tokio::test]
1577 async fn caller_abort_still_cancels_the_returned_stream() {
1578 let model = StreamModel::new(StreamBehavior::Normal);
1579 let abort_signal = AbortSignal::new();
1580 let options = GenerateTextOptions {
1581 max_retries: Some(0),
1582 abort_signal: Some(abort_signal.clone()),
1583 ..Default::default()
1584 };
1585 let mut result = stream_text(&model, "hello", options).await.unwrap();
1586
1587 abort_signal.abort();
1588
1589 assert!(matches!(
1590 result.stream.next().await.unwrap(),
1591 Err(AiMuxError::Aborted(message)) if message == "request aborted"
1592 ));
1593 }
1594
1595 #[tokio::test]
1596 async fn terminal_stream_part_stops_stream_consumption() {
1597 let model = StreamModel::new(StreamBehavior::FinishThenPending);
1598 let options = GenerateTextOptions {
1599 max_retries: Some(0),
1600 timeout: Some(crate::options::TimeoutConfiguration {
1601 total_ms: Some(50),
1602 ..Default::default()
1603 }),
1604 ..Default::default()
1605 };
1606 let mut result = stream_text(&model, "hello", options).await.unwrap();
1607
1608 assert!(matches!(
1609 result.stream.next().await.unwrap().unwrap(),
1610 StreamPart::Finish { .. }
1611 ));
1612 tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1613 assert!(result.stream.next().await.is_none());
1614 }
1615
1616 #[tokio::test]
1617 async fn provider_error_part_does_not_hide_the_final_finish() {
1618 let model = StreamModel::new(StreamBehavior::ProviderErrorThenFinish);
1619 let abort_signal = AbortSignal::new();
1620 let options = GenerateTextOptions {
1621 max_retries: Some(0),
1622 abort_signal: Some(abort_signal.clone()),
1623 ..Default::default()
1624 };
1625 let mut result = stream_text(&model, "hello", options).await.unwrap();
1626
1627 assert!(matches!(
1628 result.stream.next().await.unwrap().unwrap(),
1629 StreamPart::Error { .. }
1630 ));
1631 assert!(matches!(
1632 result.stream.next().await.unwrap().unwrap(),
1633 StreamPart::Finish { finish_reason, .. }
1634 if finish_reason.unified == crate::types::FinishReasonUnified::Error
1635 ));
1636 abort_signal.abort();
1637 assert!(result.stream.next().await.is_none());
1638 }
1639
1640 #[tokio::test]
1641 async fn timeout_wrapper_continues_after_a_malformed_stream_frame() {
1642 let model = StreamModel::new(StreamBehavior::ParseErrorThenEvent);
1643 let options = GenerateTextOptions {
1644 max_retries: Some(0),
1645 timeout: Some(crate::options::TimeoutConfiguration {
1646 total_ms: Some(1_000),
1647 ..Default::default()
1648 }),
1649 ..Default::default()
1650 };
1651 let mut result = stream_text(&model, "hello", options).await.unwrap();
1652
1653 assert!(matches!(
1654 result.stream.next().await.unwrap(),
1655 Err(AiMuxError::JsonParse(_))
1656 ));
1657 assert!(matches!(
1658 result.stream.next().await.unwrap().unwrap(),
1659 StreamPart::TextDelta { delta, .. } if delta == "after-error"
1660 ));
1661 }
1662
1663 #[tokio::test]
1664 async fn abort_wrapper_continues_after_a_malformed_stream_frame() {
1665 let model = StreamModel::new(StreamBehavior::ParseErrorThenEvent);
1666 let options = GenerateTextOptions {
1667 max_retries: Some(0),
1668 abort_signal: Some(AbortSignal::new()),
1669 ..Default::default()
1670 };
1671 let mut result = stream_text(&model, "hello", options).await.unwrap();
1672
1673 assert!(matches!(
1674 result.stream.next().await.unwrap(),
1675 Err(AiMuxError::JsonParse(_))
1676 ));
1677 assert!(matches!(
1678 result.stream.next().await.unwrap().unwrap(),
1679 StreamPart::TextDelta { delta, .. } if delta == "after-error"
1680 ));
1681 }
1682
1683 #[tokio::test]
1684 async fn first_chunk_timeout_bounds_the_stream_setup_phase() {
1685 let model = StreamModel::new(StreamBehavior::NeverReturns);
1688 let options = GenerateTextOptions {
1689 max_retries: Some(0),
1690 timeout: Some(crate::options::TimeoutConfiguration {
1691 first_chunk_ms: Some(5),
1692 ..Default::default()
1693 }),
1694 ..Default::default()
1695 };
1696 let error = stream_text(&model, "hello", options).await.unwrap_err();
1697 assert!(
1698 matches!(error, AiMuxError::Timeout(ref message) if message == "First chunk timeout of 5ms exceeded"),
1699 "got {error:?}"
1700 );
1701 }
1702
1703 #[tokio::test]
1704 async fn transport_error_ends_the_wrapped_stream() {
1705 let model = StreamModel::new(StreamBehavior::TransportErrorThenPending);
1706 let abort_signal = AbortSignal::new();
1707 let options = GenerateTextOptions {
1708 max_retries: Some(0),
1709 abort_signal: Some(abort_signal.clone()),
1710 ..Default::default()
1711 };
1712 let mut result = stream_text(&model, "hello", options).await.unwrap();
1713
1714 assert!(matches!(
1715 result.stream.next().await.unwrap(),
1716 Err(AiMuxError::ApiCall(_))
1717 ));
1718 assert!(result.stream.next().await.is_none());
1721 }
1722
1723 #[tokio::test]
1724 async fn retryable_first_stream_error_retries_before_returning_stream() {
1725 let model = StreamModel::new(StreamBehavior::RetryableFirstError);
1726 let mut result = stream_text(&model, "hello", retry_options()).await.unwrap();
1727 assert_eq!(model.calls.load(Ordering::SeqCst), 2);
1728 assert!(matches!(
1729 result.stream.next().await.unwrap().unwrap(),
1730 StreamPart::TextDelta { delta, .. } if delta == "hello"
1731 ));
1732 }
1733
1734 #[tokio::test]
1735 async fn non_retryable_first_stream_error_is_returned_unchanged() {
1736 let model = StreamModel::new(StreamBehavior::NonRetryableFirstError);
1737 let error = stream_text(&model, "hello", retry_options())
1738 .await
1739 .unwrap_err();
1740 assert!(
1741 matches!(error, AiMuxError::ApiCall(ref detail) if detail.status_code == Some(400))
1742 );
1743 assert_eq!(model.calls.load(Ordering::SeqCst), 1);
1744 }
1745}
1746
1747#[cfg(test)]
1748mod stream_aggregation_tests {
1749 use super::*;
1750 use crate::types::FinishReasonUnified;
1751
1752 fn result_from(parts: Vec<Result<StreamPart, AiMuxError>>) -> StreamTextResult {
1753 StreamTextResult {
1754 stream: Box::pin(futures::stream::iter(parts)),
1755 request_body: None,
1756 response_headers: None,
1757 }
1758 }
1759
1760 fn delta(text: &str) -> StreamPart {
1761 StreamPart::TextDelta {
1762 id: "text-1".into(),
1763 delta: text.into(),
1764 provider_metadata: None,
1765 }
1766 }
1767
1768 fn finish() -> StreamPart {
1769 StreamPart::Finish {
1770 finish_reason: FinishReason {
1771 unified: FinishReasonUnified::Stop,
1772 raw: Some("stop".into()),
1773 },
1774 usage: Usage::default(),
1775 provider_metadata: None,
1776 }
1777 }
1778
1779 #[tokio::test]
1780 async fn truncated_stream_with_partial_output_does_not_claim_a_normal_stop() {
1781 let result = result_from(vec![Ok(delta("par"))]).consume().await.unwrap();
1782 assert_eq!(result.text, "par");
1783 assert_eq!(result.finish_reason.unified, FinishReasonUnified::Other);
1784
1785 let text = result_from(vec![Ok(delta("par"))]).text().await.unwrap();
1787 assert_eq!(text, "par");
1788 }
1789
1790 #[tokio::test]
1791 async fn empty_incomplete_stream_is_an_error() {
1792 let error = result_from(vec![]).consume().await.unwrap_err();
1793 assert!(
1794 matches!(&error, AiMuxError::InvalidResponseData(message) if message.contains("without a finish chunk"))
1795 );
1796 let error = result_from(vec![]).text().await.unwrap_err();
1797 assert!(matches!(error, AiMuxError::InvalidResponseData(_)));
1798 }
1799
1800 #[tokio::test]
1801 async fn stream_with_finish_still_reports_the_provider_finish_reason() {
1802 let result = result_from(vec![Ok(delta("hi")), Ok(finish())])
1803 .consume()
1804 .await
1805 .unwrap();
1806 assert_eq!(result.finish_reason.unified, FinishReasonUnified::Stop);
1807 }
1808
1809 #[tokio::test]
1810 async fn provider_error_still_wins_after_draining_a_trailing_finish() {
1811 let parts = vec![
1812 Ok(delta("partial")),
1813 Ok(StreamPart::Error {
1814 error: AiMuxError::Other("provider error".into()),
1815 }),
1816 Ok(finish()),
1817 ];
1818 let error = result_from(parts).consume().await.unwrap_err();
1819 assert!(matches!(error, AiMuxError::Other(message) if message == "provider error"));
1820 }
1821}