Skip to main content

aimux_core/
generate.rs

1//! User-facing API: `generate_text` and `stream_text`.
2//!
3//! These are the primary entry points for users. They:
4//! 1. Convert user prompt (`ModelPrompt`) to `LanguageModelPrompt` (provider-facing).
5//! 2. Build `CallOptions` from user-facing options.
6//! 3. Call `LanguageModel::do_generate` / `do_stream`.
7//! 4. Extract / wrap the result for the user.
8
9use 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
34/// Matches the AI SDK's `isOutputChunk`: only chunks containing model output
35/// start or reset the first/chunk output timers.
36fn 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// ─────────────────────────────────────────────────────────────────────────────
47// User-facing options
48// ─────────────────────────────────────────────────────────────────────────────
49
50/// User-facing options for `generate_text` and `stream_text`.
51///
52/// Unlike `CallOptions` (provider-facing), this does not include `prompt`
53/// (passed separately) and defaults are more ergonomic.
54#[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    /// Top-level reasoning effort.
72    pub reasoning: Option<ReasoningEffort>,
73    /// System instructions prepended to the prompt.
74    pub instructions: Option<String>,
75    /// Per-call request body overrides (deep-merged). See RFC-0017.
76    pub body_overrides: Option<Value>,
77    /// Per-call retry count override. `None` = provider default, `Some(0)` = disable.
78    pub max_retries: Option<u32>,
79    /// Per-call timeout configuration (total / first-chunk / chunk idle).
80    pub timeout: Option<crate::options::TimeoutConfiguration>,
81    /// Session identifier, for grouping consecutive calls into a session
82    /// (observability, see RFC-0024). Orthogonal to RFC-0019 session-affinity
83    /// headers. When `None` and the optional session inferer is enabled, one
84    /// may be inferred from prompt-prefix continuation.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub session_id: Option<String>,
87    /// Abort signal for cancelling the call.
88    ///
89    /// Runtime handle — never crosses the JSON boundary. Rust callers set it
90    /// directly; the Node binding bridges a JS `AbortSignal` natively.
91    #[serde(skip)]
92    #[ts(skip)]
93    pub abort_signal: Option<AbortSignal>,
94    /// Emit raw provider stream chunks as `StreamPart::Raw` (debugging aid).
95    pub include_raw_chunks: Option<bool>,
96    /// Optional one-shot repair callback for unknown, malformed, or
97    /// schema-invalid tool calls.
98    #[serde(skip)]
99    #[ts(skip)]
100    pub repair_tool_call: Option<ToolCallRepair>,
101}
102
103impl GenerateTextOptions {
104    /// Build the provider-facing `CallOptions` from user-facing options.
105    #[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// ─────────────────────────────────────────────────────────────────────────────
139// User-facing result types
140// ─────────────────────────────────────────────────────────────────────────────
141
142/// Result of `generate_text` (user-facing).
143#[derive(Debug, Serialize, Deserialize, TS)]
144#[ts(export)]
145pub struct GenerateTextResult {
146    /// The generated text (concatenated from all text content parts).
147    pub text: String,
148    /// Tool calls requested by the model.
149    pub tool_calls: Vec<crate::tool::ToolCall>,
150    /// Why generation stopped.
151    pub finish_reason: FinishReason,
152    /// Token usage.
153    pub usage: Usage,
154    /// Warnings from the provider.
155    pub warnings: Vec<Warning>,
156    /// Raw provider result (for advanced use).
157    pub raw: GenerateResult,
158    // ── M7: top-level aggregation (extracted from `raw.content`) ──
159    /// Reasoning / thinking segments from the model.
160    #[serde(default)]
161    pub reasoning: Vec<ReasoningPart>,
162    /// Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`).
163    #[serde(default)]
164    pub reasoning_text: String,
165    /// Sources / citations (search-preview models).
166    #[serde(default)]
167    pub sources: Vec<SourcePart>,
168    /// Files generated by the model.
169    #[serde(default)]
170    pub files: Vec<FilePart>,
171    /// Assistant messages ready to append to the prompt for the next turn
172    /// (solves the multi-turn "manually build assistant message" footgun).
173    #[serde(default)]
174    pub response_messages: Vec<ModelMessage>,
175    /// The raw provider-specific finish reason string (e.g. "stop",
176    /// "end_turn", "safety"). Useful when `finish_reason.unified` is `Other`.
177    #[serde(default)]
178    pub raw_finish_reason: Option<String>,
179    /// Provider-specific metadata (e.g. Anthropic cache info). Mirrored from
180    /// `raw.provider_metadata` for top-level convenience.
181    #[serde(default)]
182    pub provider_metadata: Option<Value>,
183    /// Response metadata (id, timestamp, model_id). Mirrored from
184    /// `raw.response` for top-level convenience.
185    #[serde(default)]
186    pub response: crate::types::ResponseMetadata,
187    /// Total token usage across all steps. In single-step mode (aimux's
188    /// default), `total_usage` equals `usage`. Provided for AI SDK parity.
189    #[serde(default)]
190    pub total_usage: Usage,
191}
192
193/// Result of `generate_object` (user-facing, M12). The parsed JSON object plus
194/// convenience fields from the underlying `generate_text` call.
195#[derive(Debug, Serialize, Deserialize, TS)]
196#[ts(export)]
197pub struct GenerateObjectResult {
198    /// The parsed JSON object returned by the model.
199    pub object: Value,
200    /// Why generation stopped.
201    pub finish_reason: FinishReason,
202    /// Raw provider-specific finish reason string.
203    #[serde(default)]
204    pub raw_finish_reason: Option<String>,
205    /// Token usage.
206    pub usage: Usage,
207    /// Warnings from the provider.
208    pub warnings: Vec<Warning>,
209    /// Concatenated reasoning text (if the model produced reasoning/thinking).
210    #[serde(default)]
211    pub reasoning: Option<String>,
212    /// Provider-specific metadata (e.g. Anthropic cache info).
213    #[serde(default)]
214    pub provider_metadata: Option<Value>,
215    /// Response metadata (id, timestamp, model_id).
216    #[serde(default)]
217    pub response: crate::types::ResponseMetadata,
218    /// The full `generate_text` result (for advanced use).
219    pub raw: GenerateTextResult,
220}
221
222/// Result of `stream_text` (user-facing).
223pub struct StreamTextResult {
224    /// The stream of `StreamPart` items.
225    pub stream: Pin<Box<dyn Stream<Item = Result<StreamPart, AiMuxError>> + Send>>,
226    /// The request body that was sent (for debugging / cache probing, RFC-0015).
227    pub request_body: Option<serde_json::Value>,
228    /// Response headers.
229    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    /// Consume the stream and collect all text deltas into a single `String`.
244    ///
245    /// # Errors
246    ///
247    /// Returns the stream's error part or transport error, propagated from the
248    /// underlying model stream.
249    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                // A recoverable frame error is data on this stream (core keeps
259                // the stream alive past it); fold it into the same first-error
260                // slot as a provider error event and keep consuming so the
261                // trailing Finish still lands (usage, recording).
262                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                // Keep consuming after a provider error event: a trailing
280                // Finish still carries usage for the recording layer. The
281                // error is returned once the stream reaches its end.
282                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        // AI SDK semantics: an incomplete stream with no output at all is an
292        // error; an incomplete stream with partial output retains the partial
293        // result.
294        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    /// Consume the full stream and return an aggregated result (M11).
303    ///
304    /// Unlike [`text`](Self::text) (which returns only the concatenated text),
305    /// this collects reasoning, tool calls, sources, files, usage, finish
306    /// reason, and builds `response_messages` for the next turn.
307    ///
308    /// # Errors
309    ///
310    /// Returns the stream's error part or transport error, propagated from the
311    /// underlying model stream.
312    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                // A recoverable frame error is data on this stream (core keeps
336                // the stream alive past it); fold it into the same first-error
337                // slot as a provider error event and keep consuming so the
338                // trailing Finish still lands (usage, recording).
339                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                // Keep consuming after a provider error event: a trailing
454                // Finish still carries usage for the recording layer. The
455                // error is returned once the stream reaches its end.
456                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            // AI SDK semantics: an incomplete stream with no output at all is
479            // an error; an incomplete stream with partial output retains the
480            // partial result but must not claim a normal stop.
481            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
522// ─────────────────────────────────────────────────────────────────────────────
523// User-facing functions
524// ─────────────────────────────────────────────────────────────────────────────
525
526/// Generate text (non-streaming).
527///
528/// # Example
529///
530/// ```no_run
531/// use aimux_core::prelude::*;
532///
533/// # async fn example(model: &dyn LanguageModel) -> Result<(), AiMuxError> {
534/// let result = generate_text(
535///     model,
536///     "What is Rust?",
537///     GenerateTextOptions::default(),
538/// ).await?;
539///
540/// println!("{}", result.text);
541/// # Ok(())
542/// # }
543/// ```
544///
545/// # Errors
546///
547/// Returns the error produced by the model's `do_generate` call (provider API,
548/// network, or abort).
549pub async fn generate_text(
550    model: &dyn LanguageModel,
551    prompt: impl Into<ModelPrompt>,
552    options: GenerateTextOptions,
553) -> Result<GenerateTextResult, AiMuxError> {
554    // 1. Convert user prompt to provider-facing prompt.
555    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    // 2. Build CallOptions.
562    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    // 2a. RFC-0023: 关闭时零成本(M2 评审)——仅在录制开启时生成 call_id
573    //     并绑定 recorder 快照;传输封闭由层 A 收尾声明(P1 无层 B,barrier
574    //     前提;P2 移交层 B)。
575    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        // 层 A 早发 closed(defense-in-depth):无 HTTP 的调用(mock/local)也
589        // 能完成 barrier;真实 HTTP 的骨架 finalized=false 仍会挡写,由层 B
590        // 结束再发 closed 完成。双发幂等。
591        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    // 2b. Session grouping (RFC-0024): explicit session_id first, opt-in
598    //     prompt-prefix inference as fallback. Recorded before the call so
599    //     failures are still part of the session. No-op unless a
600    //     SessionStore is registered (init_session_store); the resolution
601    //     and store lookups are cheap (once-locked env check + two RwLock
602    //     reads) when no store / inferer is registered.
603    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        // RFC-0024 P3: 录制带上会话归组信息(仅录制开启时有效;
612        // recorder/call_id 同时存在才可能被写入)。
613        if let (Some(recorder), Some(call_id)) = (&recorder, &call_id) {
614            recorder.record_session(call_id, &session_id, call.step);
615        }
616    }
617
618    // 3. Call provider, inside the "generate" span (RFC-0014 §4.1 span tree).
619    //    The http layer's `http_request` span nests under this one.
620    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    // 4. Extract text, tool calls, reasoning, sources, files from content.
644    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    // Build the assistant response message content parts in parallel.
649    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            // Provider-executed results stay in the assistant message so the
717            // provider can replay its own server-tool transcript next turn.
718            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    // Parsing and optional repair are part of the user operation. Record a
751    // successful outcome only after that phase has completed within the same
752    // deadline/cancellation scope as the provider call.
753    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    // Extract fields before moving `result` into `raw`.
761    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
785/// Generate a structured JSON object (M12). Uses `generate_text` with
786/// `response_format: Json`, then parses the model output into a JSON value.
787///
788/// The schema is passed via `options.response_format = ResponseFormat::Json {
789/// schema: Some(schema), .. }`. JSON repair (`fix_json`) is applied before
790/// parsing to handle truncated or slightly malformed model output. No schema
791/// validation is performed (weak validation — relies on the model following
792/// the schema; use retries for robustness).
793///
794/// # Example
795/// ```
796/// # use aimux_core::prelude::*;
797/// # use aimux_core::options::ResponseFormat;
798/// # async fn example(model: &dyn aimux_core::LanguageModel) -> Result<(), aimux_core::error::AiMuxError> {
799/// let result = generate_object(
800///     model,
801///     "Extract: John is 25 years old.",
802///     GenerateTextOptions {
803///         response_format: Some(ResponseFormat::Json {
804///             schema: Some(serde_json::json!({
805///                 "type": "object",
806///                 "properties": { "name": { "type": "string" }, "age": { "type": "number" } },
807///                 "required": ["name", "age"]
808///             })),
809///             name: None,
810///             description: None,
811///         }),
812///         ..Default::default()
813///     },
814/// ).await?;
815/// assert_eq!(result.object["name"], "John");
816/// # Ok(())
817/// # }
818/// ```
819///
820/// # Errors
821///
822/// Propagates `generate_text` failures; additionally returns `JsonParse` when
823/// the (repaired) model output is not valid JSON.
824pub 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
860/// Extract the reasoning signature from provider metadata, checking all known
861/// provider keys (Anthropic, Bedrock).
862/// Stream text from the model.
863///
864/// # Example
865///
866/// ```no_run
867/// use aimux_core::prelude::*;
868/// use futures::StreamExt;
869///
870/// # async fn example(model: &dyn LanguageModel) -> Result<(), AiMuxError> {
871/// let result = stream_text(
872///     model,
873///     "Write a haiku about Rust.",
874///     GenerateTextOptions::default(),
875/// ).await?;
876///
877/// let mut stream = result.stream;
878/// while let Some(part) = stream.next().await {
879///     if let StreamPart::TextDelta { delta, .. } = part? {
880///         print!("{}", delta);
881///     }
882/// }
883/// # Ok(())
884/// # }
885/// ```
886///
887/// # Errors
888///
889/// Returns setup failures from the model's `do_stream` call. Provider error
890/// events surface as `Ok(StreamPart::Error { .. })`; transport failures,
891/// caller cancellation, and Core timeouts surface as `Err(AiMuxError)` stream
892/// items.
893pub async fn stream_text(
894    model: &dyn LanguageModel,
895    prompt: impl Into<ModelPrompt>,
896    options: GenerateTextOptions,
897) -> Result<StreamTextResult, AiMuxError> {
898    // 1. Convert user prompt to provider-facing prompt.
899    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    // 2. Build CallOptions.
906    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    // Armed at operation start: providers await their first SSE event inside
910    // do_stream, so the first-chunk budget must already be counting there —
911    // a 200-then-silence server is otherwise unbounded when only
912    // first_chunk_ms is configured.
913    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    // 2a. RFC-0023: 关闭时零成本(M2 评审)——仅在录制开启时生成 call_id
925    //     并绑定 recorder 快照;传输封闭由层 A 收尾声明(P1 无层 B,barrier
926    //     前提;P2 移交层 B)。
927    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        // 层 A 早发 closed(defense-in-depth):无 HTTP 的调用(mock/local)也
941        // 能完成 barrier;真实 HTTP 的骨架 finalized=false 仍会挡写,由层 B
942        // 结束再发 closed 完成。双发幂等。
943        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    // 2b. Session grouping (RFC-0024): explicit session_id first, opt-in
950    //     prompt-prefix inference as fallback. Recorded before the call so
951    //     failures are still part of the session. No-op unless a
952    //     SessionStore is registered (init_session_store); the resolution
953    //     and store lookups are cheap (once-locked env check + two RwLock
954    //     reads) when no store / inferer is registered.
955    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        // RFC-0024 P3: 录制带上会话归组信息(仅录制开启时有效;
964        // recorder/call_id 同时存在才可能被写入)。
965        if let (Some(recorder), Some(call_id)) = (&recorder, &call_id) {
966            recorder.record_session(call_id, &session_id, call.step);
967        }
968    }
969
970    // 3. Call provider, inside the "generate" span (RFC-0014 §4.1 span tree).
971    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        // Call-level retry boundary: once do_stream returns a stream, retry
983        // no longer recreates the provider request; the returned stream is
984        // consumed by the stream-step timeout logic below.
985        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    // 解构避免部分 move(result 各字段去向不同)。
999    let StreamResult {
1000        stream,
1001        request_body,
1002        response_headers,
1003    } = result;
1004
1005    // The consumption phase keeps observing the same deadlines: the
1006    // first-chunk budget armed at operation start continues until the first
1007    // output chunk.
1008    let operation_deadline = operation_timeout.deadline();
1009    // A pump task drives the provider stream, so the deadlines below measure
1010    // when provider output *arrives*: a deadline observed only inside
1011    // `poll_next` keeps elapsing while nobody polls, charging the provider
1012    // for the consumer's latency. The channel is unbounded so the pump never
1013    // blocks on the consumer (which would put that latency back into the
1014    // timers); the AI SDK's `streamText` consumes eagerly for the same
1015    // reason, and buffering is bounded by the response itself.
1016    //
1017    // Spawned even with no abort signal or deadlines armed: the pump is also
1018    // the stream's terminal fuse — `Finish` and non-recoverable errors must
1019    // end the stream under every configuration, or a provider that keeps the
1020    // connection open after `Finish` hangs a consumer reading to end-of-stream.
1021    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                    // Dropping the provider stream cancels the in-flight HTTP
1033                    // exchange; the consumer side reports the abort error.
1034                    () = 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                // A closed receiver means the consumer dropped the stream.
1068                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                // Mirror the pump's fuse here so the stream ends right after
1089                // the terminal item, ahead of a later abort or the channel
1090                // close racing it.
1091                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    // 录制开启时才包装(终结时写 outcome + 传输封闭);关闭时零成本透传。
1146    let stream = crate::recording::RecordingOutcomeStream::new(
1147        stream,
1148        recorder.clone(),
1149        call_id.unwrap_or_default(),
1150    );
1151
1152    // 4. Return wrapped stream to user (request_body/response_headers kept for
1153    //    debugging / cache probing, RFC-0015).
1154    Ok(StreamTextResult {
1155        stream: Box::pin(stream),
1156        request_body,
1157        response_headers,
1158    })
1159}
1160
1161/// A malformed individual frame does not prove that the transport is dead:
1162/// SSE framing lets a later event still be valid. `Finish` and transport/Core
1163/// errors are terminal so the stream is not polled past a dead source.
1164fn 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
1169/// Aborts the stream pump task when the returned stream is dropped, so an
1170/// abandoned stream leaves no task driving the provider connection.
1171struct AbortOnDrop(tokio::task::JoinHandle<()>);
1172
1173impl Drop for AbortOnDrop {
1174    fn drop(&mut self) {
1175        self.0.abort();
1176    }
1177}
1178/// Run `do_generate` inside the RFC-0014 `generate` span and emit the
1179/// `generate_end` event. A plain async fn (rather than an inline async block)
1180/// so the `?` error type is pinned by the declared return type.
1181async 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
1200/// Split a `ModelPrompt` into messages + optional instructions.
1201///
1202/// If the prompt is a plain string, it becomes a single user message.
1203/// If the prompt is messages, they are used as-is.
1204/// Instructions are passed through separately (they get prepended by
1205/// `convert_to_language_model_prompt`).
1206pub(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
1216// ─────────────────────────────────────────────────────────────────────────────
1217// OpenAI Chat Completions output (RFC-0026)
1218// ─────────────────────────────────────────────────────────────────────────────
1219
1220use crate::openai_output::{
1221    ChatCompletion, ChatCompletionFunction, ChatCompletionStream, ChatCompletionToolCall,
1222    OpenAiStreamOptions, parsed_tool_call_arguments, to_chat_completion, to_chat_completion_stream,
1223};
1224
1225/// Generate text and return the result as an OpenAI Chat Completion.
1226///
1227/// This is the OpenAI-compatible equivalent of [`generate_text`]. Internally
1228/// it calls `generate_text`, then converts the [`GenerateResult`] into a
1229/// [`ChatCompletion`] via [`to_chat_completion`].
1230/// Tool calls reflect Core parsing and any configured repair; provider
1231/// response metadata, usage, and non-tool content are preserved from `raw`.
1232///
1233/// Works with **any** provider (OpenAI, Anthropic, Google, …) — the output is
1234/// always standard OpenAI Chat Completions JSON.
1235///
1236/// # Example
1237///
1238/// ```no_run
1239/// use aimux_core::prelude::*;
1240///
1241/// # async fn example(model: &dyn LanguageModel) -> Result<(), AiMuxError> {
1242/// let completion = generate_text_as_openai(
1243///     model,
1244///     "What is Rust?",
1245///     GenerateTextOptions::default(),
1246/// ).await?;
1247///
1248/// println!("{}", completion.choices[0].message.content.as_deref().unwrap());
1249/// println!("{}", completion.usage.prompt_tokens);
1250/// # Ok(())
1251/// # }
1252/// ```
1253///
1254/// # Errors
1255///
1256/// Propagates any error from the underlying `generate_text` call.
1257pub 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/// Convert a [`GenerateTextResult`] into an OpenAI [`ChatCompletion`].
1270///
1271/// The pure half of [`generate_text_as_openai`]: tool calls come from
1272/// `result.tool_calls` (so they reflect Core parsing and any repair already
1273/// applied to the result), everything else from `result.raw` via
1274/// [`to_chat_completion`]. Hosts that repair a result themselves (RFC-0035)
1275/// convert it with this afterwards.
1276///
1277/// `model_id` is the fallback for `ChatCompletion.model` when the provider
1278/// response carries no model id.
1279#[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
1312/// Stream text and return the result as a stream of OpenAI Chat Completion chunks.
1313///
1314/// This is the OpenAI-compatible equivalent of [`stream_text`]. Internally
1315/// it calls `stream_text`, then converts the `StreamPart` stream into a
1316/// `ChatCompletionChunk` stream via [`to_chat_completion_stream`].
1317///
1318/// Works with **any** provider (OpenAI, Anthropic, Google, …) — the output is
1319/// always standard OpenAI Chat Completions streaming chunks.
1320///
1321/// Tool-call argument deltas are the provider's text, forwarded verbatim as it
1322/// arrives, so this stream does **not** reflect tool-call repair — aligned
1323/// with the AI SDK, which likewise never withholds input deltas. Use
1324/// [`generate_text_as_openai`] or the native `stream_text` when repaired calls
1325/// are required.
1326///
1327/// # Example
1328///
1329/// ```no_run
1330/// use aimux_core::prelude::*;
1331/// use futures::StreamExt;
1332///
1333/// # async fn example(model: &dyn LanguageModel) -> Result<(), AiMuxError> {
1334/// let result = stream_text_as_openai(
1335///     model,
1336///     "Write a haiku about Rust.",
1337///     GenerateTextOptions::default(),
1338///     OpenAiStreamOptions::default(),
1339/// ).await?;
1340///
1341/// let mut stream = result.stream;
1342/// while let Some(chunk) = stream.next().await {
1343///     let chunk = chunk?;
1344///     if let Some(delta) = chunk.choices.first()
1345///         && let Some(content) = &delta.delta.content {
1346///         print!("{}", content);
1347///     }
1348/// }
1349/// # Ok(())
1350/// # }
1351/// ```
1352///
1353/// # Errors
1354///
1355/// Propagates any error from the underlying `stream_text` call.
1356pub 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        // The provider yields immediately; the first-chunk budget measures
1553        // that arrival, not when the caller gets around to polling. Sleeping
1554        // past the budget before the first poll must not turn delivered
1555        // output into a timeout.
1556        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        // Providers await their first SSE event inside do_stream; a
1686        // 200-then-silence server must be bounded by first_chunk_ms alone.
1687        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        // Without treating Err as terminal this next() would hang on the
1719        // pending tail of the dead source stream.
1720        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        // text() retains the partial result (AI SDK semantics).
1786        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}