diff --git a/CHANGELOG.md b/CHANGELOG.md index bdac7ea98..746dfdf78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- Provider results carry request and response information as the AI SDK does + (`aimux-core`). `GenerateResult` has `request: Option` and + `response: Option`; `ResponseInfo` gains `id` and holds the + timestamp, model id, headers and body. `StreamResult` has `request` and + `response: Option`. The flat `request_body` and + `response_headers` fields are removed and `response` is optional; the call + layer fills a missing id, timestamp and model id. The user-facing results and + their JSON are unchanged. + - The prompt a provider receives is modelled by role (`aimux-core`), as the AI SDK's `LanguageModelV4Message`. `LanguageModelPrompt` is `Vec`: `System { content: String }`, diff --git a/MIGRATION-NS.md b/MIGRATION-NS.md index 631c4c433..df3bf050b 100644 --- a/MIGRATION-NS.md +++ b/MIGRATION-NS.md @@ -155,9 +155,8 @@ fn f(meta: Option) -> StreamPart { ... } ``` Local variables typed `Option` / `HashMap` that end up in these fields: retype them. -Intermediate scratch values that are not themselves stored (e.g. `StreamingToolCallTracker::with_extract_metadata` -closures, which still return `Option`) stay `Value`; `with_build_provider_metadata` still takes `Option<&Value>` -and returns `Option`, so build with `provider_namespace("google", json!({...}))`. +This includes `StreamingToolCallDelta::provider_metadata`, which is an `Option`: build it +with `provider_namespace("google", json!({...}))`. ### 7. Tests comparing against `json!` diff --git a/aimux-core/src/error.rs b/aimux-core/src/error.rs index 59e779b14..e39bf2be8 100644 --- a/aimux-core/src/error.rs +++ b/aimux-core/src/error.rs @@ -175,6 +175,9 @@ pub enum AiMuxError { #[error("JSON parse error: {0}")] JsonParse(String), + #[error("{0}")] + NoOutputGenerated(String), + #[error("invalid response data: {0}")] InvalidResponseData(String), diff --git a/aimux-core/src/generate.rs b/aimux-core/src/generate.rs index fb614db66..622219230 100644 --- a/aimux-core/src/generate.rs +++ b/aimux-core/src/generate.rs @@ -32,6 +32,25 @@ use crate::tool::Tool; use crate::types::{FinishReason, ProviderMetadata, ReasoningEffort, Usage, Warning}; use crate::{AbortSignal, retry, timeout}; +fn response_metadata_defaults( + mut metadata: crate::types::ResponseMetadata, + model_id: &str, +) -> crate::types::ResponseMetadata { + metadata.id.get_or_insert_with(|| { + use rand::Rng; + rand::thread_rng() + .sample_iter(rand::distributions::Alphanumeric) + .take(16) + .map(char::from) + .collect() + }); + metadata + .timestamp + .get_or_insert_with(crate::util::rfc3339_now); + metadata.model_id.get_or_insert_with(|| model_id.to_owned()); + metadata +} + /// Matches the AI SDK's `isOutputChunk`: only chunks containing model output /// start or reset the first/chunk output timers. fn is_output_chunk(part: &StreamPart) -> bool { @@ -200,10 +219,12 @@ pub struct GenerateTextResult { /// `raw.provider_metadata` for top-level convenience. #[serde(default)] pub provider_metadata: Option, - /// Response metadata (id, timestamp, model_id). Mirrored from - /// `raw.response` for top-level convenience. + /// Request information from the provider. #[serde(default)] - pub response: crate::types::ResponseMetadata, + pub request: crate::shared::RequestInfo, + /// Response metadata, headers, and body from the provider. + #[serde(default)] + pub response: crate::shared::ResponseInfo, /// Total token usage across all steps. In single-step mode (aimux's /// default), `total_usage` equals `usage`. Provided for AI SDK parity. #[serde(default)] @@ -287,19 +308,19 @@ impl StreamTextResult { } Err(error) => return Err(error), }; - if is_output_chunk(&part) { + if part.is_output_chunk() { saw_output = true; } match part { - StreamPart::TextDelta { delta, .. } => result.push_str(&delta), - StreamPart::Finish { .. } => { + TextStreamPart::TextDelta { delta, .. } => result.push_str(&delta), + TextStreamPart::Finish { .. } => { saw_finish = true; break; } // Keep consuming after a provider error event: a trailing // Finish still carries usage for the recording layer. The // error is returned once the stream reaches its end. - StreamPart::Error { error } if provider_error.is_none() => { + TextStreamPart::Error { error } if provider_error.is_none() => { provider_error = Some(error); } _ => {} @@ -312,7 +333,7 @@ impl StreamTextResult { // error; an incomplete stream with partial output retains the partial // result. if !saw_finish && !saw_output { - return Err(AiMuxError::InvalidResponseData( + return Err(AiMuxError::NoOutputGenerated( "No output generated. The model stream ended without a finish chunk.".into(), )); } @@ -342,7 +363,13 @@ impl StreamTextResult { let mut raw_finish_reason: Option = None; let mut usage = Usage::default(); let mut finish_provider_metadata: Option = None; - let mut response: Option = None; + let mut response = crate::shared::ResponseInfo { + headers: self.response_headers, + ..Default::default() + }; + let request = crate::shared::RequestInfo { + body: self.request_body, + }; let mut rm = crate::response_messages::ResponseMessageBuilder::new(); let mut saw_output = false; @@ -364,15 +391,15 @@ impl StreamTextResult { } Err(error) => return Err(error), }; - if is_output_chunk(&part) { + if part.is_output_chunk() { saw_output = true; } match part { - StreamPart::TextStart { + TextStreamPart::TextStart { id, provider_metadata, } => rm.text_start(id, provider_metadata), - StreamPart::TextDelta { + TextStreamPart::TextDelta { id, delta, provider_metadata, @@ -380,46 +407,46 @@ impl StreamTextResult { text.push_str(&delta); rm.text_delta(id, &delta, provider_metadata); } - StreamPart::TextEnd { + TextStreamPart::TextEnd { id, provider_metadata, } => rm.text_end(id, provider_metadata), - StreamPart::ReasoningStart { + TextStreamPart::ReasoningStart { id, provider_metadata, } => rm.reasoning_start(id, provider_metadata), - StreamPart::ReasoningDelta { + TextStreamPart::ReasoningDelta { id, delta, provider_metadata, } => rm.reasoning_delta(id, &delta, provider_metadata), - StreamPart::ReasoningEnd { + TextStreamPart::ReasoningEnd { id, provider_metadata, } => rm.reasoning_end(id, provider_metadata), - StreamPart::ToolCall(call) => { + TextStreamPart::ToolCall(call) => { rm.tool_call(&call); tool_calls.push(call); } - StreamPart::ToolResult(result) => rm.tool_result(result), - StreamPart::Source(source) => { + TextStreamPart::ToolResult(result) => rm.tool_result(result), + TextStreamPart::Source(source) => { rm.source(source.clone()); sources.push(source); } - StreamPart::File(file) => { + TextStreamPart::File(file) => { rm.file(&file, false); files.push(file); } - StreamPart::ReasoningFile(output) => rm.file(&output.file, true), - StreamPart::Custom { + TextStreamPart::ReasoningFile(output) => rm.file(&output.file, true), + TextStreamPart::Custom { kind, provider_metadata, } => rm.custom(kind, provider_metadata), - StreamPart::ToolApprovalRequest(approval) => rm.approval(&approval), - StreamPart::StreamStart { warnings: w, .. } => { + TextStreamPart::ToolApprovalRequest(approval) => rm.approval(&approval), + TextStreamPart::StreamStart { warnings: w, .. } => { warnings = w; } - StreamPart::Finish { + TextStreamPart::Finish { finish_reason: fr, usage: u, provider_metadata: pm, @@ -434,10 +461,12 @@ impl StreamTextResult { // Keep consuming after a provider error event: a trailing // Finish still carries usage for the recording layer. The // error is returned once the stream reaches its end. - StreamPart::Error { error } if provider_error.is_none() => { + TextStreamPart::Error { error } if provider_error.is_none() => { provider_error = Some(error); } - StreamPart::ResponseMetadata(meta) => response = Some(meta), + TextStreamPart::FinishStep { + response: metadata, .. + } => response = metadata, _ => {} } } @@ -450,7 +479,7 @@ impl StreamTextResult { // an error; an incomplete stream with partial output retains the // partial result but must not claim a normal stop. if !saw_output { - return Err(AiMuxError::InvalidResponseData( + return Err(AiMuxError::NoOutputGenerated( "No output generated. The model stream ended without a finish chunk.".into(), )); } @@ -489,6 +518,7 @@ impl StreamTextResult { usage, warnings, provider_metadata: finish_provider_metadata, + request, response, response_messages, }) @@ -617,7 +647,7 @@ pub(crate) async fn generate_text_from_language_model_prompt( } do_generate_with_logging(model, &call_options, span.clone(), started) }); - let result = match timeout::run(operation, abort_signal.as_ref(), operation_timeout).await { + let mut result = match timeout::run(operation, abort_signal.as_ref(), operation_timeout).await { Ok(r) => r, Err(e) => { if let (Some(rec), Some(call_id)) = (&recorder, &call_id) { @@ -626,6 +656,19 @@ pub(crate) async fn generate_text_from_language_model_prompt( return Err(e); } }; + let response_info = result.response.get_or_insert_with(Default::default); + let response = response_metadata_defaults( + crate::types::ResponseMetadata { + id: response_info.id.take(), + timestamp: response_info.timestamp.take(), + model_id: response_info.model_id.take(), + }, + model.model_id(), + ); + response_info.id.clone_from(&response.id); + response_info.timestamp.clone_from(&response.timestamp); + response_info.model_id.clone_from(&response.model_id); + // 4. Extract text, tool calls, reasoning, sources, files from content. let mut text = String::new(); let mut tool_calls = Vec::new(); @@ -738,8 +781,9 @@ pub(crate) async fn generate_text_from_language_model_prompt( // Extract fields before moving `result` into `raw`. let raw_finish_reason = result.finish_reason.raw.clone(); let provider_metadata = result.provider_metadata.clone(); - let response = result.response.clone().map(Into::into).unwrap_or_default(); let usage = result.usage.clone(); + let request = result.request.take().unwrap_or_default(); + let response = result.response.take().unwrap_or_default(); Ok(GenerateTextResult { content, @@ -756,6 +800,7 @@ pub(crate) async fn generate_text_from_language_model_prompt( response_messages, raw_finish_reason, provider_metadata, + request, response, total_usage: usage, }) @@ -822,12 +867,11 @@ pub async fn generate_object( Some(text_result.reasoning_text.clone()) }; let provider_metadata = text_result.raw.provider_metadata.clone(); - let response = text_result - .raw - .response - .clone() - .map(Into::into) - .unwrap_or_default(); + let response = crate::types::ResponseMetadata { + id: text_result.response.id.clone(), + timestamp: text_result.response.timestamp.clone(), + model_id: text_result.response.model_id.clone(), + }; Ok(GenerateObjectResult { object, finish_reason, @@ -841,8 +885,6 @@ pub async fn generate_object( }) } -/// Extract the reasoning signature from provider metadata, checking all known -/// provider keys (Anthropic, Bedrock). /// Stream text from the model. /// /// # Example @@ -860,7 +902,7 @@ pub async fn generate_object( /// /// let mut stream = result.stream; /// while let Some(part) = stream.next().await { -/// if let StreamPart::TextDelta { delta, .. } = part? { +/// if let TextStreamPart::TextDelta { delta, .. } = part? { /// print!("{}", delta); /// } /// } @@ -1084,11 +1126,39 @@ pub async fn stream_text( }) }; let mut stream = stream; + let mut response_metadata = + response_metadata_defaults(crate::types::ResponseMetadata::default(), model.model_id()); + let stream_response_headers = response_headers.clone(); let stream: Pin> + Send>> = Box::pin( async_stream::stream! { + let mut saw_terminal = false; + let mut saw_output = false; + let mut finish_reason = FinishReason { unified: crate::types::FinishReasonUnified::Other, raw: None }; + let mut usage = Usage::default(); + let mut provider_metadata = None; let mut tool_calls = HashMap::new(); while let Some(item) = stream.next().await { + if let Ok(part) = &item { + saw_output |= is_output_chunk(part); + } match item { + Ok(StreamPart::ResponseMetadata(metadata)) => { + if metadata.id.is_some() { response_metadata.id = metadata.id; } + if metadata.timestamp.is_some() { response_metadata.timestamp = metadata.timestamp; } + if metadata.model_id.is_some() { response_metadata.model_id = metadata.model_id; } + + } + Ok(StreamPart::Finish { finish_reason: reason, usage: tokens, provider_metadata: metadata }) => { + saw_terminal = true; + finish_reason = reason; + usage = tokens; + provider_metadata = metadata; + } + Ok(StreamPart::Error { error }) => { + saw_terminal = true; + finish_reason = FinishReason { unified: crate::types::FinishReasonUnified::Error, raw: None }; + yield Ok(TextStreamPart::Error { error }); + } Ok(StreamPart::ToolCall(raw)) => { let parsed = parse_tool_call( raw, @@ -1098,42 +1168,60 @@ pub async fn stream_text( operation_instructions.as_deref(), ).await; tool_calls.insert(parsed.tool_call_id.clone(), parsed.clone()); - yield Ok(StreamPart::ToolCall(parsed)); + yield Ok(TextStreamPart::ToolCall(parsed)); } Ok(StreamPart::File(file)) => { match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { - Ok(file) => yield Ok(StreamPart::File(file)), - Err(error) => yield Ok(StreamPart::Error { error }), + Ok(file) => yield Ok(TextStreamPart::File(file)), + Err(error) => yield Ok(TextStreamPart::Error { error }), } } Ok(StreamPart::ReasoningFile(file)) => { match timeout::run(resolve_file(file, file_abort_signal.as_ref()), file_abort_signal.as_ref(), operation_timeout).await { - Ok(file) => yield Ok(StreamPart::ReasoningFile(ReasoningFileOutput { + Ok(file) => yield Ok(TextStreamPart::ReasoningFile(ReasoningFileOutput { provider_metadata: file.provider_metadata.clone(), file, })), - Err(error) => yield Ok(StreamPart::Error { error }), + Err(error) => yield Ok(TextStreamPart::Error { error }), } } Ok(StreamPart::ToolApprovalRequest(approval)) => { if let Some(call) = tool_calls.get(&approval.tool_call_id) { - yield Ok(StreamPart::ToolApprovalRequest(ToolApprovalRequestOutput { + yield Ok(TextStreamPart::ToolApprovalRequest(ToolApprovalRequestOutput { approval_id: approval.approval_id, tool_call: call.clone(), reason: None, is_automatic: None, signature: None, })); } else { - yield Ok(StreamPart::Error { error: AiMuxError::ToolCallNotFoundForApproval { + yield Ok(TextStreamPart::Error { error: AiMuxError::ToolCallNotFoundForApproval { tool_call_id: approval.tool_call_id, approval_id: approval.approval_id, }}); } } Ok(part) => yield Ok(part.map_payloads(|_| unreachable!("matched above"), |_| unreachable!("matched above")) - .map_reasoning_file(|file| ReasoningFileOutput { provider_metadata: file.provider_metadata.clone(), file })), - Err(error) => yield Err(error), + .map_reasoning_file(|file| ReasoningFileOutput { provider_metadata: file.provider_metadata.clone(), file }) + .into_text_stream_part().expect("provider lifecycle parts handled above")), + Err(error) => { + let terminal = !error.is_recoverable_stream_error(); + yield Err(error); + if terminal { return; } + } } } + if !saw_terminal && !saw_output { + yield Ok(TextStreamPart::Error { error: AiMuxError::NoOutputGenerated( + "No output generated. The model stream ended without a finish chunk.".into(), + ) }); + return; + } + let mut response = crate::shared::ResponseInfo::from(response_metadata); + response.headers = stream_response_headers; + yield Ok(TextStreamPart::FinishStep { + finish_reason: finish_reason.clone(), usage: usage.clone(), + provider_metadata: provider_metadata.clone(), response, + }); + yield Ok(TextStreamPart::Finish { finish_reason, usage, provider_metadata }); }, ); - // 录制开启时才包装(终结时写 outcome + 传输封闭);关闭时零成本透传。 + let stream = crate::recording::RecordingOutcomeStream::new( stream, recorder.clone(), @@ -1273,6 +1361,12 @@ pub fn generate_text_result_to_chat_completion( model_id: &str, ) -> ChatCompletion { let mut completion = to_chat_completion(&result.raw, model_id); + if let Some(id) = &result.response.id { + completion.id.clone_from(id); + } + if let Some(model_id) = &result.response.model_id { + completion.model.clone_from(model_id); + } if let Some(choice) = completion.choices.first_mut() { choice.message.tool_calls = if result.tool_calls.is_empty() { None diff --git a/aimux-core/src/lib.rs b/aimux-core/src/lib.rs index 8dbd7a26f..98e3f362e 100644 --- a/aimux-core/src/lib.rs +++ b/aimux-core/src/lib.rs @@ -107,9 +107,8 @@ pub mod prelude { RerankingCallOptions, RerankingModel, RerankingResult, rerank, }; pub use crate::result::{ - GenerateResponse, GenerateResult, GeneratedFile, RawToolApprovalRequest, ReasoningOutput, - ReasoningPart, Source, StreamResponse, StreamResult, TextContent, - ToolApprovalRequestOutput, + GenerateResult, GeneratedFile, RawToolApprovalRequest, ReasoningOutput, ReasoningPart, + Source, StreamResult, TextContent, ToolApprovalRequestOutput, }; pub use crate::router::{ FallbackPolicy, Router, RouterConfig, RouterModel, RuleRouter, WeightedRouter, diff --git a/aimux-core/src/moa.rs b/aimux-core/src/moa.rs index edf4b3000..129371048 100644 --- a/aimux-core/src/moa.rs +++ b/aimux-core/src/moa.rs @@ -295,7 +295,7 @@ impl LanguageModel for MoaModel { } }; - // RFC-0022 §3.4: return None for request_body/response_headers. The + // RFC-0022 §3.4: return None for request/response. The // aggregator's request body is a synthesized prompt (references // spliced in), not the user's original — exposing it would mislead // cache probing (RFC-0015 fingerprinting). diff --git a/aimux-core/src/openai_output.rs b/aimux-core/src/openai_output.rs index bc26fa3ae..9b581eece 100644 --- a/aimux-core/src/openai_output.rs +++ b/aimux-core/src/openai_output.rs @@ -1,6 +1,6 @@ //! OpenAI Chat Completions output format. //! -//! Converts aimux's internal types ([`GenerateResult`] / [`StreamPart`]) into +//! Converts aimux's internal types ([`GenerateResult`] / [`TextStreamPart`]) into //! standard OpenAI Chat Completions structures ([`ChatCompletion`] / //! [`ChatCompletionChunk`]). This lets any provider (OpenAI, Anthropic, Google, //! …) be consumed via the OpenAI wire format. @@ -9,7 +9,7 @@ //! //! ```text //! provider.do_generate() → GenerateResult → to_chat_completion() → ChatCompletion -//! provider.do_stream() → Stream → to_chat_completion_stream() → Stream +//! stream_text() → Stream → to_chat_completion_stream() → Stream //! ``` //! //! The conversion is a post-processing step — it does not modify the @@ -37,9 +37,9 @@ use crate::error::AiMuxError; use crate::parse_tool_call::raw_tool_call_text; use crate::result::{GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source}; use crate::shared::GeneratedFileData; -use crate::stream_part::{StreamPart, TextStreamPart}; +use crate::stream_part::TextStreamPart; use crate::tool::{ToolCall, ToolResult}; -use crate::types::{FinishReason, FinishReasonUnified, ResponseMetadata, Usage}; +use crate::types::{FinishReason, FinishReasonUnified, Usage}; // ───────────────────────────────────────────────────────────────────────────── // Non-streaming response types @@ -377,7 +377,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio } // ───────────────────────────────────────────────────────────────────────────── -// Streaming conversion: Stream → Stream +// Streaming conversion: Stream → Stream // ───────────────────────────────────────────────────────────────────────────── /// A streaming OpenAI Chat Completions result. @@ -394,7 +394,7 @@ impl std::fmt::Debug for ChatCompletionStream { } } -/// Convert a `StreamPart` stream into a `ChatCompletionChunk` stream. +/// Convert a `TextStreamPart` stream into a `ChatCompletionChunk` stream. /// /// Uses a stateful converter that maintains tool-call indices and accumulates /// the final usage / finish_reason. The output follows OpenAI SSE conventions: @@ -445,7 +445,7 @@ pub fn to_chat_completion_stream( } // After Finish, stop (but we already emitted the final chunk). - if matches!(part, StreamPart::Finish { .. }) { + if matches!(part, TextStreamPart::Finish { .. }) { return; } } @@ -536,7 +536,7 @@ impl StreamState { Some(chunk) } - /// Process a single StreamPart, returning zero or more output chunks. + /// Process a single TextStreamPart, returning zero or more output chunks. fn process_part( &mut self, part: &TextStreamPart, @@ -546,27 +546,30 @@ impl StreamState { let mut chunks = Vec::new(); match part { - StreamPart::StreamStart { .. } => { + TextStreamPart::StreamStart { .. } => { if let Some(c) = self.ensure_started() { chunks.push(c); } } - StreamPart::ResponseMetadata(ResponseMetadata { id, model_id, .. }) => { - if let Some(id) = id { - self.id = id.clone(); - } - if let Some(m) = model_id { - self.model = m.clone(); + TextStreamPart::FinishStep { response, .. } => { + // Response metadata arrives at step end; emitted chunks keep their identity. + if !self.started { + if let Some(id) = &response.id { + self.id = id.clone(); + } + if let Some(model_id) = &response.model_id { + self.model = model_id.clone(); + } } } - StreamPart::TextStart { .. } => { + TextStreamPart::TextStart { .. } => { if let Some(c) = self.ensure_started() { chunks.push(c); } } - StreamPart::TextDelta { delta, .. } => { + TextStreamPart::TextDelta { delta, .. } => { if let Some(c) = self.ensure_started() { chunks.push(c); } @@ -582,10 +585,10 @@ impl StreamState { }]; chunks.push(chunk); } - StreamPart::TextEnd { .. } => {} + TextStreamPart::TextEnd { .. } => {} - StreamPart::ReasoningStart { .. } => {} - StreamPart::ReasoningDelta { delta, .. } => { + TextStreamPart::ReasoningStart { .. } => {} + TextStreamPart::ReasoningDelta { delta, .. } => { if include_reasoning { if let Some(c) = self.ensure_started() { chunks.push(c); @@ -603,9 +606,9 @@ impl StreamState { chunks.push(chunk); } } - StreamPart::ReasoningEnd { .. } => {} + TextStreamPart::ReasoningEnd { .. } => {} - StreamPart::ToolInputStart { id, tool_name, .. } => { + TextStreamPart::ToolInputStart { id, tool_name, .. } => { if let Some(c) = self.ensure_started() { chunks.push(c); } @@ -649,7 +652,7 @@ impl StreamState { }]; chunks.push(chunk); } - StreamPart::ToolInputDelta { id, delta, .. } => { + TextStreamPart::ToolInputDelta { id, delta, .. } => { // Ensure started (shouldn't happen without Start, but be safe). if let Some(c) = self.ensure_started() { chunks.push(c); @@ -697,9 +700,9 @@ impl StreamState { }]; chunks.push(chunk); } - StreamPart::ToolInputEnd { .. } => {} + TextStreamPart::ToolInputEnd { .. } => {} - StreamPart::ToolCall(ToolCall { + TextStreamPart::ToolCall(ToolCall { tool_call_id, tool_name, input, @@ -788,7 +791,7 @@ impl StreamState { } } - StreamPart::ToolResult(ToolResult { + TextStreamPart::ToolResult(ToolResult { tool_name, result, .. }) => { // Degraded: provider-executed tool result as content. @@ -809,7 +812,7 @@ impl StreamState { chunks.push(chunk); } - StreamPart::File(GeneratedFile { + TextStreamPart::File(GeneratedFile { data, media_type, .. }) => { if let Some(c) = self.ensure_started() { @@ -829,7 +832,7 @@ impl StreamState { chunks.push(chunk); } - StreamPart::Source(Source::Url { url, .. }) => { + TextStreamPart::Source(Source::Url { url, .. }) => { if let Some(c) = self.ensure_started() { chunks.push(c); } @@ -849,7 +852,7 @@ impl StreamState { } } - StreamPart::Finish { + TextStreamPart::Finish { finish_reason, usage, .. @@ -862,17 +865,17 @@ impl StreamState { } } - StreamPart::Error { error } => { + TextStreamPart::Error { error } => { if let Some(chunk) = self.error_chunk(error) { chunks.push(chunk); } } - StreamPart::Raw { .. } - | StreamPart::Source(Source::Document { .. }) - | StreamPart::Custom { .. } - | StreamPart::ReasoningFile(_) - | StreamPart::ToolApprovalRequest(_) => { /* no OpenAI chat equivalent */ } + TextStreamPart::Raw { .. } + | TextStreamPart::Source(Source::Document { .. }) + | TextStreamPart::Custom { .. } + | TextStreamPart::ReasoningFile(_) + | TextStreamPart::ToolApprovalRequest(_) => { /* no OpenAI chat equivalent */ } } chunks @@ -1019,7 +1022,7 @@ fn now_unix() -> u64 { // Rejected repairs retain the original call. Recover its text from the // original error, never from the replacement's validation failure. -/// Render a Core-parsed `StreamPart::ToolCall`'s arguments as OpenAI-compatible +/// Render a Core-parsed `TextStreamPart::ToolCall`'s arguments as OpenAI-compatible /// wire text: the provider's raw argument text verbatim for an invalid call, /// compact JSON of the parsed value otherwise. /// diff --git a/aimux-core/src/parse_tool_call.rs b/aimux-core/src/parse_tool_call.rs index 053245b6e..a784bbc6e 100644 --- a/aimux-core/src/parse_tool_call.rs +++ b/aimux-core/src/parse_tool_call.rs @@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use ts_rs::TS; +use crate::content::ContentPart; use crate::error::AiMuxError; +use crate::result::{GenerateContent, TextContent}; use crate::tool::{RawToolCall, Tool, ToolCall}; /// Context supplied to a one-shot tool-call repair callback. @@ -507,6 +509,11 @@ pub fn apply_tool_call_repair( /// is untouched, because it is parsed from the model's text, not from a tool /// call. /// +/// The matching `content` entry, and the call embedded in a matching +/// approval request, are replaced with the repaired call as well; a renamed +/// call also renames the transcript's approval request. `raw` is the +/// provider's output and stays as it was. +/// /// The OpenAI-shaped `ChatCompletion` is deliberately not supported: it drops /// `invalid` and `error`, so a host cannot tell from it that a call needs /// repairing in the first place. Repair the native result and convert. @@ -603,5 +610,199 @@ pub fn apply_tool_call_repair_to_result( part.insert("input".to_string(), replay_input.clone()); } + // `content` carries the same call, and an approval request embeds it, so + // both must agree with `tool_calls` too. `raw` stays as is: it is the + // provider's output, which a repair does not change. + for entry in target + .get_mut("content") + .and_then(Value::as_array_mut) + .into_iter() + .flatten() + { + let Ok(mut content) = serde_json::from_value::(entry.clone()) else { + continue; + }; + let call = match &mut content { + GenerateContent::ToolCall(call) => call, + GenerateContent::ToolApprovalRequest(approval) => &mut approval.tool_call, + _ => continue, + }; + if call.tool_call_id != tool_call_id { + continue; + } + *call = repaired.clone(); + *entry = serde_json::to_value(&content) + .map_err(|error| AiMuxError::InvalidArgument(format!("content: {error}")))?; + } + if repaired.tool_call_id != tool_call_id { + for part in target + .get_mut("response_messages") + .and_then(Value::as_array_mut) + .into_iter() + .flatten() + .filter_map(|message| message.get_mut("content")?.as_array_mut()) + .flatten() + { + let Ok(mut approval) = serde_json::from_value::(part.clone()) else { + continue; + }; + let ContentPart::ToolApprovalRequest { + tool_call_id: approved_id, + .. + } = &mut approval + else { + continue; + }; + if approved_id != tool_call_id { + continue; + } + approved_id.clone_from(&repaired.tool_call_id); + *part = serde_json::to_value(&approval).map_err(|error| { + AiMuxError::InvalidArgument(format!("response message: {error}")) + })?; + } + } + Ok(patched) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::MessageContent; + use crate::response_messages::ResponseMessageBuilder; + use crate::result::{StreamTextResultAggregated, ToolApprovalRequestOutput}; + use crate::tool::FunctionTool; + use crate::types::{FinishReason, FinishReasonUnified}; + + fn weather_tools() -> Vec { + vec![Tool::Function(FunctionTool::new( + "weather", + serde_json::json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + }), + ))] + } + + fn raw_call(id: &str, input: &str) -> RawToolCall { + RawToolCall { + tool_call_id: id.to_string(), + tool_name: "weather".to_string(), + input: input.to_string(), + provider_executed: None, + dynamic: None, + provider_metadata: None, + } + } + + /// A result document as `stream_text().consume()` assembles it, holding + /// one call the model got wrong (`city` is a number). + async fn result_with_invalid_call(with_approval: bool) -> Value { + let tools = weather_tools(); + let call = parse_tool_call( + raw_call("call-1", r#"{"city":7}"#), + Some(&tools), + None, + &[], + None, + ) + .await; + assert_eq!(call.invalid, Some(true)); + let mut builder = ResponseMessageBuilder::default(); + builder.tool_call(&call); + if with_approval { + builder.approval(&ToolApprovalRequestOutput { + approval_id: "approval-1".to_string(), + tool_call: call.clone(), + reason: None, + is_automatic: None, + signature: None, + }); + } + let assembled = builder.finish(); + serde_json::to_value(StreamTextResultAggregated { + content: assembled.content, + text: String::new(), + reasoning: Vec::new(), + reasoning_text: String::new(), + tool_calls: vec![call], + sources: Vec::new(), + files: Vec::new(), + finish_reason: FinishReason { + unified: FinishReasonUnified::ToolCalls, + raw: None, + }, + raw_finish_reason: None, + usage: crate::types::Usage::default(), + total_usage: crate::types::Usage::default(), + warnings: Vec::new(), + provider_metadata: None, + request: crate::shared::RequestInfo::default(), + response: crate::shared::ResponseInfo::default(), + response_messages: assembled.messages, + }) + .unwrap() + } + + fn repair(result: &Value, tool_call_id: &str) -> StreamTextResultAggregated { + let patched = apply_tool_call_repair_to_result( + result, + Some(&weather_tools()), + "call-1", + ToolCallRepairReply::Repaired { + tool_call: raw_call(tool_call_id, r#"{"city":"Tokyo"}"#), + }, + ) + .unwrap(); + serde_json::from_value(patched).unwrap() + } + + fn content_calls(result: &StreamTextResultAggregated) -> Vec<&ToolCall> { + result + .content + .iter() + .filter_map(|content| match content { + GenerateContent::ToolCall(call) => Some(call), + GenerateContent::ToolApprovalRequest(approval) => Some(&approval.tool_call), + _ => None, + }) + .collect() + } + + #[tokio::test] + async fn a_repair_also_updates_the_content_call() { + let repaired = repair(&result_with_invalid_call(false).await, "call-1"); + + let expected = serde_json::to_value(&repaired.tool_calls[0]).unwrap(); + assert_eq!(expected["input"], serde_json::json!({ "city": "Tokyo" })); + let calls = content_calls(&repaired); + assert_eq!(calls.len(), 1); + assert_eq!(serde_json::to_value(calls[0]).unwrap(), expected); + } + + #[tokio::test] + async fn a_renaming_repair_updates_approval_requests() { + let repaired = repair(&result_with_invalid_call(true).await, "call-2"); + + let expected = serde_json::to_value(&repaired.tool_calls[0]).unwrap(); + let calls = content_calls(&repaired); + assert_eq!(calls.len(), 2); + for call in calls { + assert_eq!(serde_json::to_value(call).unwrap(), expected); + } + let MessageContent::Parts(parts) = &repaired.response_messages[0].content else { + panic!("expected content parts"); + }; + let ids: Vec<_> = parts + .iter() + .map(|part| match part { + ContentPart::ToolCall { tool_call_id, .. } + | ContentPart::ToolApprovalRequest { tool_call_id, .. } => tool_call_id.as_str(), + other => panic!("unexpected part {other:?}"), + }) + .collect(); + assert_eq!(ids, ["call-2", "call-2"]); + } +} diff --git a/aimux-core/src/recording.rs b/aimux-core/src/recording.rs index 25ad2fc6f..204ecabfb 100644 --- a/aimux-core/src/recording.rs +++ b/aimux-core/src/recording.rs @@ -1626,12 +1626,50 @@ impl RecordingOutcomeStream { } } -impl futures::Stream for RecordingOutcomeStream +enum StreamOutcome<'a> { + Finish(&'a crate::types::FinishReason, &'a crate::types::Usage), + Error(&'a crate::AiMuxError), + Other, +} + +trait RecordingStreamPart { + fn recording_outcome(&self) -> StreamOutcome<'_>; +} + +impl RecordingStreamPart for crate::stream_part::StreamPart { + fn recording_outcome(&self) -> StreamOutcome<'_> { + match self { + Self::Finish { + finish_reason, + usage, + .. + } => StreamOutcome::Finish(finish_reason, usage), + Self::Error { error } => StreamOutcome::Error(error), + _ => StreamOutcome::Other, + } + } +} + +impl RecordingStreamPart for crate::stream_part::TextStreamPart { + fn recording_outcome(&self) -> StreamOutcome<'_> { + match self { + Self::Finish { + finish_reason, + usage, + .. + } => StreamOutcome::Finish(finish_reason, usage), + Self::Error { error } => StreamOutcome::Error(error), + _ => StreamOutcome::Other, + } + } +} + +impl futures::Stream for RecordingOutcomeStream where - S: futures::Stream, crate::AiMuxError>> - + Unpin, + S: futures::Stream> + Unpin, + P: RecordingStreamPart, { - type Item = Result, crate::AiMuxError>; + type Item = Result; fn poll_next( mut self: std::pin::Pin<&mut Self>, @@ -1641,12 +1679,8 @@ where let inner = std::pin::Pin::new(&mut this.inner); match inner.poll_next(cx) { std::task::Poll::Ready(Some(Ok(part))) => { - match &part { - crate::stream_part::StreamPart::Finish { - finish_reason, - usage, - .. - } => { + match part.recording_outcome() { + StreamOutcome::Finish(finish_reason, usage) => { let this = self.as_mut().get_mut(); if let Some(mut pending) = this.pending.take() { // Provider error 已定性;尾随 Finish 补上 usage。 @@ -1666,7 +1700,7 @@ where this.record(&outcome); } } - crate::stream_part::StreamPart::Error { error } => { + StreamOutcome::Error(error) => { let this = self.as_mut().get_mut(); if !this.recorded && this.pending.is_none() { this.pending = Some(OutcomeRecord { diff --git a/aimux-core/src/result.rs b/aimux-core/src/result.rs index aad084f05..678947ce3 100644 --- a/aimux-core/src/result.rs +++ b/aimux-core/src/result.rs @@ -1,18 +1,15 @@ //! Result types for `do_generate` and `do_stream`. -use std::collections::HashMap; - use futures::Stream; use serde::{Deserialize, Serialize}; use std::pin::Pin; use ts_rs::TS; use crate::error::AiMuxError; -pub use crate::shared::GeneratedFileData; -use crate::shared::RequestInfo; +use crate::shared::{GeneratedFileData, RequestInfo, ResponseInfo, StreamResponseInfo}; use crate::stream_part::StreamPart; use crate::tool::{RawToolCall, ToolResult}; -use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warning}; +use crate::types::{FinishReason, ProviderMetadata, Usage, Warning}; /// A content item in the generation result. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] @@ -195,9 +192,12 @@ pub struct StreamTextResultAggregated { /// Provider-specific metadata from the Finish chunk. #[serde(default)] pub provider_metadata: Option, - /// Response metadata (id, timestamp, model_id) if emitted by the stream. + /// Request information from the provider. + #[serde(default)] + pub request: RequestInfo, + /// Response metadata and headers from the last step. #[serde(default)] - pub response: Option, + pub response: ResponseInfo, /// Assistant messages ready to append for the next turn. #[serde(default)] pub response_messages: Vec, @@ -205,8 +205,7 @@ pub struct StreamTextResultAggregated { /// Result of `LanguageModel::do_generate` (non-streaming). #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[serde(from = "GenerateResultWire", into = "GenerateResultWire")] -#[ts(export, as = "GenerateResultWire")] +#[ts(export)] pub struct GenerateResult { /// Generated content items (text, tool calls, etc.). pub content: Vec, @@ -218,18 +217,20 @@ pub struct GenerateResult { pub warnings: Vec, /// Provider-specific metadata. pub provider_metadata: Option, - /// Optional response information. - pub response: Option, - /// Optional request information. + /// Optional request information for telemetry and debugging. pub request: Option, + /// Optional response metadata, headers, and body. + pub response: Option, } /// Result of `LanguageModel::do_stream` (streaming). pub struct StreamResult { /// The stream of `StreamPart` items. pub stream: Pin> + Send>>, + /// Optional request information for telemetry and debugging. pub request: Option, - pub response: Option, + /// Optional response headers. + pub response: Option, } impl std::fmt::Debug for StreamResult { @@ -241,120 +242,3 @@ impl std::fmt::Debug for StreamResult { .finish() } } - -/// Response metadata and HTTP details from a generation call. -#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct GenerateResponse { - pub id: Option, - pub timestamp: Option, - pub model_id: Option, - pub headers: Option>, - pub body: Option, -} - -impl From for GenerateResponse { - fn from(metadata: ResponseMetadata) -> Self { - Self { - id: metadata.id, - timestamp: metadata.timestamp, - model_id: metadata.model_id, - ..Default::default() - } - } -} - -impl From for ResponseMetadata { - fn from(response: GenerateResponse) -> Self { - Self { - id: response.id, - timestamp: response.timestamp, - model_id: response.model_id, - } - } -} - -/// HTTP details from a streaming call. -#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] -#[ts(export)] -pub struct StreamResponse { - pub headers: Option>, -} - -// Preserve the established binding wire shape while exposing upstream's -// request and response objects to Rust callers. -#[derive(Serialize, Deserialize, TS)] -struct GenerateResultWire { - content: Vec, - finish_reason: FinishReason, - usage: Usage, - warnings: Vec, - provider_metadata: Option, - #[ts(inline)] - response: Option, - request_body: Option, - response_headers: Option>, -} - -#[derive(Serialize, Deserialize, TS)] -struct GenerateResponseWire { - id: Option, - timestamp: Option, - model_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - body: Option, -} - -impl From for GenerateResult { - fn from(wire: GenerateResultWire) -> Self { - Self { - content: wire.content, - finish_reason: wire.finish_reason, - usage: wire.usage, - warnings: wire.warnings, - provider_metadata: wire.provider_metadata, - request: wire - .request_body - .map(|body| RequestInfo { body: Some(body) }), - response: wire - .response - .map(|meta| GenerateResponse { - id: meta.id, - timestamp: meta.timestamp, - model_id: meta.model_id, - headers: wire.response_headers.clone(), - body: meta.body, - }) - .or_else(|| { - wire.response_headers.map(|headers| GenerateResponse { - headers: Some(headers), - ..Default::default() - }) - }), - } - } -} - -impl From for GenerateResultWire { - fn from(result: GenerateResult) -> Self { - let response_headers = result - .response - .as_ref() - .and_then(|response| response.headers.clone()); - Self { - content: result.content, - finish_reason: result.finish_reason, - usage: result.usage, - warnings: result.warnings, - provider_metadata: result.provider_metadata, - request_body: result.request.and_then(|request| request.body), - response_headers, - response: result.response.map(|meta| GenerateResponseWire { - id: meta.id, - timestamp: meta.timestamp, - model_id: meta.model_id, - body: meta.body, - }), - } - } -} diff --git a/aimux-core/src/shared.rs b/aimux-core/src/shared.rs index c1ca198f6..db673e627 100644 --- a/aimux-core/src/shared.rs +++ b/aimux-core/src/shared.rs @@ -1,4 +1,4 @@ -//! Shared V4 value types used across all non-chat model traits. +//! Shared V4 value types used across model traits. //! //! Aligned with Vercel AI SDK `SharedV4*` types //! (`reference/ai/packages/provider/src/shared/v4/`). @@ -271,6 +271,9 @@ fn parse_pair(s: &str, sep: char) -> Option<(u32, u32)> { #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[ts(export)] pub struct ResponseInfo { + /// ID of the generated response, when supplied by the provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, /// Timestamp for the start of the generated response (ISO 8601 string). pub timestamp: Option, /// The ID of the model that was used to generate the response. @@ -290,3 +293,21 @@ pub struct RequestInfo { /// The request body that was sent (opaque JSON). pub body: Option, } + +/// Response information returned when starting a language-model stream. +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct StreamResponseInfo { + pub headers: Option, +} + +impl From for ResponseInfo { + fn from(metadata: crate::types::ResponseMetadata) -> Self { + Self { + id: metadata.id, + timestamp: metadata.timestamp, + model_id: metadata.model_id, + ..Self::default() + } + } +} diff --git a/aimux-core/src/stream_part.rs b/aimux-core/src/stream_part.rs index 20ff84d43..16c5bbb16 100644 --- a/aimux-core/src/stream_part.rs +++ b/aimux-core/src/stream_part.rs @@ -20,7 +20,7 @@ use crate::types::{FinishReason, ProviderMetadata, ResponseMetadata, Usage, Warn /// [`StreamPart`] (raw argument text); `stream_text` emits [`TextStreamPart`] /// (parsed and validated [`ToolCall`]). #[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, concrete(C = ToolCall, A = ToolApprovalRequestOutput, R = ReasoningFileOutput))] +#[ts(export, concrete(C = RawToolCall, A = RawToolApprovalRequest, R = GeneratedFile))] pub enum StreamPart { // ── P0: text ── /// Start of a text segment. @@ -142,8 +142,132 @@ pub enum StreamPart; +/// A public call-layer stream chunk, with parsed tool calls. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub enum TextStreamPart { + // ── P0: text ── + /// Start of a text segment. + TextStart { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + /// A delta of generated text. + TextDelta { + id: String, + delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + /// End of a text segment. + TextEnd { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + + // ── P0: stream lifecycle ── + /// First chunk — carries warnings from the provider. + StreamStart { + warnings: Vec, + }, + /// Final chunk — carries usage, finish reason, and metadata. + Finish { + finish_reason: FinishReason, + usage: Usage, + provider_metadata: Option, + }, + /// A completed model call, including its response metadata. + FinishStep { + finish_reason: FinishReason, + usage: Usage, + provider_metadata: Option, + response: crate::shared::ResponseInfo, + }, + /// An error occurred mid-stream. + Error { + error: AiMuxError, + }, + + // ── P1: tool calls ── + /// Start of a tool call's input streaming. + ToolInputStart { + id: String, + tool_name: String, + /// Whether the tool call will be executed by the provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_executed: Option, + /// Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + #[serde(default, skip_serializing_if = "Option::is_none")] + dynamic: Option, + /// Optional title for the tool call. + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + /// A delta of tool call input (partial JSON). + ToolInputDelta { + id: String, + delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + /// End of a tool call's input streaming. + ToolInputEnd { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + /// A complete tool call (alternative to the start/delta/end flow). + /// + /// The provider layer emits [`RawToolCall`] (`input` is the raw argument + /// text); `stream_text` re-emits the parsed [`ToolCall`] ([`TextStreamPart`]). + ToolCall(ToolCall), + /// A tool result (provider-executed tools). + ToolResult(ToolResult), + + // ── P2: file ── + /// A file generated by the model (e.g. an image or document). + File(GeneratedFile), + ReasoningFile(ReasoningFileOutput), + Custom { + kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, + }, + ToolApprovalRequest(ToolApprovalRequestOutput), + + // ── P2: reasoning ── + ReasoningStart { + id: String, + /// Provider-specific metadata (e.g. xAI `itemId`). + #[allow(unused)] + provider_metadata: Option, + }, + ReasoningDelta { + id: String, + delta: String, + /// Provider-specific metadata (e.g. xAI `itemId`). + #[allow(unused)] + provider_metadata: Option, + }, + ReasoningEnd { + id: String, + /// Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). + #[allow(unused)] + provider_metadata: Option, + }, + + /// A source / citation (e.g. URL citation from search-preview models). + Source(Source), + + /// A raw chunk from the provider (for debugging, when `include_raw_chunks` is set). + Raw { + raw_value: Value, + }, +} impl StreamPart { /// Replace the tool-call payload, leaving every other variant untouched. @@ -209,3 +333,51 @@ impl StreamPart { } } } + +impl StreamPart { + /// Convert content chunks; the call layer absorbs metadata and finish chunks. + #[must_use] + pub fn into_text_stream_part(self) -> Option { + macro_rules! carry { + ($($variant:ident { $($field:ident),* })*) => { + match self { + Self::ResponseMetadata(_) | Self::Finish { .. } => None, + Self::ToolCall(value) => Some(TextStreamPart::ToolCall(value)), + Self::ToolApprovalRequest(value) => Some(TextStreamPart::ToolApprovalRequest(value)), + Self::ToolResult(value) => Some(TextStreamPart::ToolResult(value)), + Self::File(value) => Some(TextStreamPart::File(value)), + Self::ReasoningFile(value) => Some(TextStreamPart::ReasoningFile(value)), + Self::Source(value) => Some(TextStreamPart::Source(value)), + $(Self::$variant { $($field),* } => Some(TextStreamPart::$variant { $($field),* }),)* + } + }; + } + carry! { + TextStart { id, provider_metadata } + TextDelta { id, delta, provider_metadata } + TextEnd { id, provider_metadata } + StreamStart { warnings } + Error { error } + ToolInputStart { id, tool_name, provider_executed, dynamic, title, provider_metadata } + ToolInputDelta { id, delta, provider_metadata } + ToolInputEnd { id, provider_metadata } + ReasoningStart { id, provider_metadata } + ReasoningDelta { id, delta, provider_metadata } + ReasoningEnd { id, provider_metadata } + Custom { kind, provider_metadata } + Raw { raw_value } + } + } +} + +impl TextStreamPart { + pub(crate) fn is_output_chunk(&self) -> bool { + match self { + Self::TextDelta { delta, .. } + | Self::ReasoningDelta { delta, .. } + | Self::ToolInputDelta { delta, .. } => !delta.is_empty(), + Self::ToolCall(_) | Self::File(_) | Self::ReasoningFile(_) => true, + _ => false, + } + } +} diff --git a/aimux-ffi/aimux-error.h b/aimux-ffi/aimux-error.h index 81ef6928b..bfe50be7c 100644 --- a/aimux-ffi/aimux-error.h +++ b/aimux-ffi/aimux-error.h @@ -6,7 +6,7 @@ * trailing out-parameter, which remains at its documented sentinel on failure. * * Every non-NULL error has one non-zero `aimux_error_code_t` and one message. - * Codes 1..17 come from `AiMuxError`, 100..105 from + * Codes 1..18 come from `AiMuxError`, 100..105 from * `RecordingError`, and 200..206 identify failures detected while crossing * the C ABI. Higher-level bindings reconstruct their native error types from * that code; they map all 200..206 codes to the language's existing @@ -40,7 +40,7 @@ typedef struct aimux_error aimux_error_t; typedef enum aimux_error_code { AIMUX_OK = 0, - /* AiMuxError: 1..17, except retired code 4. */ + /* AiMuxError: 1..18, except retired code 4. */ AIMUX_E_OTHER = 1, AIMUX_E_JSON_PARSE = 2, AIMUX_E_INVALID_RESPONSE_DATA = 3, @@ -60,6 +60,7 @@ typedef enum aimux_error_code { AIMUX_E_NO_SUCH_TOOL = 15, AIMUX_E_INVALID_TOOL_INPUT = 16, AIMUX_E_TOOL_CALL_REPAIR = 17, + AIMUX_E_NO_OUTPUT_GENERATED = 18, /* RecordingError: 100..105. */ AIMUX_E_RECORDING_INIT = 100, diff --git a/aimux-ffi/src/lib.rs b/aimux-ffi/src/lib.rs index 8f4c7ef81..c1e4805ef 100644 --- a/aimux-ffi/src/lib.rs +++ b/aimux-ffi/src/lib.rs @@ -439,6 +439,7 @@ pub const AIMUX_E_RETRY: i32 = 14; pub const AIMUX_E_NO_SUCH_TOOL: i32 = 15; pub const AIMUX_E_INVALID_TOOL_INPUT: i32 = 16; pub const AIMUX_E_TOOL_CALL_REPAIR: i32 = 17; +pub const AIMUX_E_NO_OUTPUT_GENERATED: i32 = 18; // 100..105 preserve `RecordingError` as a separate high-level type while C // uses one code space for every returned error. @@ -469,6 +470,7 @@ fn aimux_error_code_of(err: &AiMuxError) -> i32 { AiMuxError::ApiCall { .. } => AIMUX_E_API_CALL, AiMuxError::Retry(_) => AIMUX_E_RETRY, AiMuxError::JsonParse(_) => AIMUX_E_JSON_PARSE, + AiMuxError::NoOutputGenerated(_) => AIMUX_E_NO_OUTPUT_GENERATED, AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { AIMUX_E_INVALID_RESPONSE_DATA } @@ -3586,7 +3588,7 @@ mod tests { fn expect_aimux_error(e: *mut aimux_error_t) -> (i32, String) { assert!(!e.is_null(), "expected a returned error"); let code = aimux_error_code(e); - if !(AIMUX_E_OTHER..=AIMUX_E_TOOL_CALL_REPAIR).contains(&code) { + if !(AIMUX_E_OTHER..=AIMUX_E_NO_OUTPUT_GENERATED).contains(&code) { panic!("expected an AiMuxError code, got {code}: {}", msg(e)); } let out = (code, take(aimux_error_message(e)).unwrap()); diff --git a/aimux-providers/src/anthropic/stream.rs b/aimux-providers/src/anthropic/stream.rs index 92604e3dd..48c185ec5 100644 --- a/aimux-providers/src/anthropic/stream.rs +++ b/aimux-providers/src/anthropic/stream.rs @@ -875,6 +875,8 @@ pub(crate) async fn anthropic_generate_core( .await?; let response_body = resp.raw_value; + let response_headers = resp.response_headers; + let data: AnthropicResponse = resp.value; let content = parse_anthropic_content(&data.content, tool_names, generate_id); @@ -899,16 +901,14 @@ pub(crate) async fn anthropic_generate_core( usage, warnings, provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { + response: Some(aimux_core::shared::ResponseInfo { + id: Some(data.id), + timestamp: None, + model_id: Some(data.model), + headers: Some(response_headers), body: response_body, - headers: None, - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: Some(data.id), - timestamp: None, - model_id: Some(data.model), - }) }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -1424,7 +1424,7 @@ pub(crate) async fn anthropic_stream_core( Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/azure/responses.rs b/aimux-providers/src/azure/responses.rs index 800cd428a..75b65e234 100644 --- a/aimux-providers/src/azure/responses.rs +++ b/aimux-providers/src/azure/responses.rs @@ -300,7 +300,7 @@ impl LanguageModel for AzureResponsesModel { async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()).await?; - let request_result = build_responses_request_body(&self.deployment, options, true)?; + let request_result = build_responses_request_body(&self.deployment, options, false)?; let mut body = request_result.body; let warnings = request_result.warnings; let provider_key = provider_key().to_string(); @@ -319,9 +319,11 @@ impl LanguageModel for AzureResponsesModel { == Some(true); let endpoint = self.endpoint(); + let mut stream_body = body.clone(); + stream_body["stream"] = Value::Bool(true); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new(endpoint.clone(), build_header_list(&headers), options), - body.clone(), + stream_body, aimux_provider_utils::create_event_source_response_handler::(), crate::openai::openai_failed_response_handler(), ) @@ -348,7 +350,7 @@ impl LanguageModel for AzureResponsesModel { Ok(StreamResult { stream, request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/bedrock/model.rs b/aimux-providers/src/bedrock/model.rs index e88314a8a..cc9a7852e 100644 --- a/aimux-providers/src/bedrock/model.rs +++ b/aimux-providers/src/bedrock/model.rs @@ -254,16 +254,14 @@ impl LanguageModel for BedrockModel { usage, warnings: Vec::new(), provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { + response: Some(aimux_core::shared::ResponseInfo { + id: request_id, + timestamp: response_headers.get("date").cloned(), + model_id: Some(self.model_id.clone()), + headers: Some(response_headers), body: response_body, - headers: Some(response_headers.clone()), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: request_id, - timestamp: response_headers.get("date").cloned(), - model_id: Some(self.model_id.clone()), - }) }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -618,7 +616,7 @@ impl LanguageModel for BedrockModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/codex.rs b/aimux-providers/src/codex.rs index 5cf6b16f7..1621a8ff8 100644 --- a/aimux-providers/src/codex.rs +++ b/aimux-providers/src/codex.rs @@ -426,7 +426,7 @@ impl CodexModel { Ok(StreamResult { stream, request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/cohere/model.rs b/aimux-providers/src/cohere/model.rs index 59438d9c8..374140fa7 100644 --- a/aimux-providers/src/cohere/model.rs +++ b/aimux-providers/src/cohere/model.rs @@ -252,16 +252,14 @@ impl LanguageModel for CohereModel { usage, warnings: body_result.warnings, provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: response_body, + response: Some(aimux_core::shared::ResponseInfo { + id: data.generation_id, + timestamp: None, + model_id: None, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: data.generation_id, - timestamp: None, - model_id: None, - }) + body: response_body, }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -580,7 +578,7 @@ impl LanguageModel for CohereModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/google/model.rs b/aimux-providers/src/google/model.rs index 712c51b38..8d0ebe62f 100644 --- a/aimux-providers/src/google/model.rs +++ b/aimux-providers/src/google/model.rs @@ -221,16 +221,14 @@ impl LanguageModel for GoogleModel { usage, warnings: tool_warnings, provider_metadata, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: response_body, + response: Some(aimux_core::shared::ResponseInfo { + id: data.response_id, + timestamp: None, + model_id: None, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: data.response_id, - timestamp: None, - model_id: None, - }) + body: response_body, }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -726,7 +724,7 @@ impl LanguageModel for GoogleModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/huggingface/responses.rs b/aimux-providers/src/huggingface/responses.rs index adb27505e..e5b89df5b 100644 --- a/aimux-providers/src/huggingface/responses.rs +++ b/aimux-providers/src/huggingface/responses.rs @@ -223,20 +223,18 @@ impl LanguageModel for HuggingFaceResponsesModel { finish_reason, usage, warnings: request.warnings, - provider_metadata: Some( - provider_namespace("huggingface", json!({ "responseId": response_id })) - .expect("provider metadata must be an object"), - ), - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: response_body, + provider_metadata: Some(provider_namespace( + "huggingface", + json!({ "responseId": response_id }), + )?), + response: Some(aimux_core::shared::ResponseInfo { + id: response_id, + timestamp: format_timestamp(created_at), + model_id: model, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: response_id, - timestamp: format_timestamp(created_at), - model_id: model, - }) + body: response_body, }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -554,7 +552,7 @@ impl LanguageModel for HuggingFaceResponsesModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/mistral/model.rs b/aimux-providers/src/mistral/model.rs index 74e713e32..b6441250d 100644 --- a/aimux-providers/src/mistral/model.rs +++ b/aimux-providers/src/mistral/model.rs @@ -307,19 +307,17 @@ impl LanguageModel for MistralModel { usage, warnings: Vec::new(), provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: response_body, + response: Some(aimux_core::shared::ResponseInfo { + id: data.id, + timestamp: data + .created + .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) + .map(|dt| dt.to_rfc3339()), + model_id: data.model, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: data.id, - timestamp: data - .created - .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) - .map(|dt| dt.to_rfc3339()), - model_id: data.model, - }) + body: response_body, }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -610,7 +608,7 @@ impl LanguageModel for MistralModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/open_responses.rs b/aimux-providers/src/open_responses.rs index c684cc6eb..bc6f46dbe 100644 --- a/aimux-providers/src/open_responses.rs +++ b/aimux-providers/src/open_responses.rs @@ -447,28 +447,24 @@ impl LanguageModel for OpenResponsesModel { usage, warnings, provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: response_body, + response: Some(aimux_core::shared::ResponseInfo { headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(response) + body: response_body, + ..response.into() }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } async fn do_stream(&self, options: &CallOptions) -> Result { - let headers = self.build_headers(options.headers.as_ref()); validate_tool_output_media(&options.prompt)?; - let (body, warnings) = + let headers = self.build_headers(options.headers.as_ref()); + let (mut stream_body, warnings) = build_request_body(&self.model_id, options, &self.config.provider_options_name); - let stream_body = { - let mut b = body.clone(); - if let Some(obj) = b.as_object_mut() { - obj.insert("stream".to_string(), json!(true)); - } - b - }; + if let Some(obj) = stream_body.as_object_mut() { + obj.insert("stream".to_string(), json!(true)); + } let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new( @@ -828,8 +824,10 @@ impl LanguageModel for OpenResponsesModel { Ok(StreamResult { stream: Box::pin(stream), - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + request: Some(aimux_core::shared::RequestInfo { + body: Some(stream_body), + }), + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/openai/model.rs b/aimux-providers/src/openai/model.rs index 48f24c62f..9ab26b76d 100644 --- a/aimux-providers/src/openai/model.rs +++ b/aimux-providers/src/openai/model.rs @@ -386,19 +386,17 @@ pub async fn execute_generate( usage, warnings: request_result.warnings, provider_metadata, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: Some(response_value), + response: Some(aimux_core::shared::ResponseInfo { + id: Some(data.id), + timestamp: data + .created + .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) + .map(|dt| dt.to_rfc3339()), + model_id: Some(data.model), headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: Some(data.id), - timestamp: data - .created - .and_then(|secs| chrono::DateTime::from_timestamp(secs as i64, 0)) - .map(|dt| dt.to_rfc3339()), - model_id: Some(data.model), - }) + body: Some(response_value), }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -782,7 +780,7 @@ pub async fn execute_stream( Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/openai/responses/mod.rs b/aimux-providers/src/openai/responses/mod.rs index 38ed6f196..eb703841e 100644 --- a/aimux-providers/src/openai/responses/mod.rs +++ b/aimux-providers/src/openai/responses/mod.rs @@ -155,7 +155,7 @@ impl LanguageModel for OpenAIResponsesModel { async fn do_stream(&self, options: &CallOptions) -> Result { let headers = self.build_headers(options.headers.as_ref()); - let request_result = build_responses_request_body(&self.model_id, options, true)?; + let request_result = build_responses_request_body(&self.model_id, options, false)?; let body = request_result.body; let warnings = request_result.warnings; let provider_key = self.provider_options_name().to_string(); @@ -171,9 +171,11 @@ impl LanguageModel for OpenAIResponsesModel { == Some(true); let endpoint = self.endpoint(); + let mut stream_body = body.clone(); + stream_body["stream"] = Value::Bool(true); let resp = aimux_provider_utils::post_json_to_api( HttpRequest::new(endpoint.clone(), build_header_list(&headers), options), - body.clone(), + stream_body, aimux_provider_utils::create_event_source_response_handler::(), super::openai_failed_response_handler(), ) @@ -200,7 +202,7 @@ impl LanguageModel for OpenAIResponsesModel { Ok(StreamResult { stream, request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/openai/responses/responses_convert.rs b/aimux-providers/src/openai/responses/responses_convert.rs index e593b11ad..610823ed1 100644 --- a/aimux-providers/src/openai/responses/responses_convert.rs +++ b/aimux-providers/src/openai/responses/responses_convert.rs @@ -354,16 +354,14 @@ pub fn build_responses_generate_result( usage, warnings: request_warnings, provider_metadata, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: Some(data.clone()), + response: Some(aimux_core::shared::ResponseInfo { + id: response_id, + timestamp, + model_id: model, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: response_id, - timestamp, - model_id: model, - }) + body: Some(data.clone()), }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } diff --git a/aimux-providers/src/vertex/anthropic_model.rs b/aimux-providers/src/vertex/anthropic_model.rs index 68d84b36c..cd6aef2b7 100644 --- a/aimux-providers/src/vertex/anthropic_model.rs +++ b/aimux-providers/src/vertex/anthropic_model.rs @@ -200,6 +200,7 @@ impl LanguageModel for VertexAnthropicModel { .await?; let response_body = resp.raw_value; + let response_headers = resp.response_headers; let data: AnthropicResponse = resp.value; let content = crate::anthropic::stream::parse_anthropic_content( @@ -226,16 +227,14 @@ impl LanguageModel for VertexAnthropicModel { usage, warnings: req.warnings, provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { + response: Some(aimux_core::shared::ResponseInfo { + id: Some(data.id), + timestamp: None, + model_id: Some(data.model), + headers: Some(response_headers), body: response_body, - headers: None, - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: Some(data.id), - timestamp: None, - model_id: Some(data.model), - }) }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -612,7 +611,7 @@ impl LanguageModel for VertexAnthropicModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/vertex/model.rs b/aimux-providers/src/vertex/model.rs index 20a7163ed..84c2be4ff 100644 --- a/aimux-providers/src/vertex/model.rs +++ b/aimux-providers/src/vertex/model.rs @@ -16,10 +16,9 @@ use aimux_core::error::AiMuxError; use aimux_core::language_model::LanguageModel; use aimux_core::options::CallOptions; use aimux_core::result::{ - GenerateContent, GenerateResult, GeneratedFile, GeneratedFileData, ReasoningOutput, Source, - StreamResult, + GenerateContent, GenerateResult, GeneratedFile, ReasoningOutput, Source, StreamResult, }; -use aimux_core::shared::{FileBytes, provider_namespace}; +use aimux_core::shared::{FileBytes, GeneratedFileData, provider_namespace}; use aimux_core::stream_part::StreamPart; use aimux_core::types::{ FinishReason, FinishReasonUnified, ProviderMetadata, ResponseMetadata, Usage, @@ -288,18 +287,16 @@ impl LanguageModel for VertexModel { usage, warnings: Vec::new(), provider_metadata, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { + response: Some(aimux_core::shared::ResponseInfo { + id: data.response_id, + // Gemini responses carry no timestamp field; use the response + // `Date` header (RFC1123) like Bedrock does. + timestamp: response_headers.get("date").cloned(), + model_id: data.model_version, + headers: Some(response_headers), body: response_body, - headers: Some(response_headers.clone()), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: data.response_id, - // Gemini responses carry no timestamp field; use the response - // `Date` header (RFC1123) like Bedrock does. - timestamp: response_headers.get("date").cloned(), - model_id: data.model_version, - }) }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -838,7 +835,7 @@ impl LanguageModel for VertexModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/xai/model.rs b/aimux-providers/src/xai/model.rs index 234bd6259..96e37840d 100644 --- a/aimux-providers/src/xai/model.rs +++ b/aimux-providers/src/xai/model.rs @@ -122,9 +122,9 @@ impl LanguageModel for XaiModel { .await?; // Capture response headers. - let response_body = resp.raw_value; let response_headers = resp.response_headers; + let response_value = resp.raw_value.unwrap_or(Value::Null); let data = resp.value; let choice = data @@ -229,16 +229,14 @@ impl LanguageModel for XaiModel { usage, warnings: request_result.warnings, provider_metadata: None, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: response_body, + response: Some(aimux_core::shared::ResponseInfo { + id: data.id, + timestamp, + model_id: data.model, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: data.id, - timestamp, - model_id: data.model, - }) + body: Some(response_value), }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -592,7 +590,7 @@ impl LanguageModel for XaiModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/src/xai/responses/mod.rs b/aimux-providers/src/xai/responses/mod.rs index 2f33c978b..fce9e7198 100644 --- a/aimux-providers/src/xai/responses/mod.rs +++ b/aimux-providers/src/xai/responses/mod.rs @@ -121,7 +121,7 @@ impl LanguageModel for XaiResponsesModel { let response_headers = resp.response_headers; - let raw_value = resp.raw_value.unwrap_or(Value::Null); + let response_value = resp.raw_value.unwrap_or(Value::Null); let data = resp.value; let mut content: Vec = Vec::new(); @@ -349,16 +349,14 @@ impl LanguageModel for XaiResponsesModel { usage, warnings: request_result.warnings, provider_metadata, - request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::GenerateResponse { - body: Some(raw_value), + response: Some(aimux_core::shared::ResponseInfo { + id: data.id, + timestamp, + model_id: data.model, headers: Some(response_headers), - ..aimux_core::result::GenerateResponse::from(ResponseMetadata { - id: data.id, - timestamp, - model_id: data.model, - }) + body: Some(response_value), }), + request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), }) } @@ -930,7 +928,7 @@ impl LanguageModel for XaiResponsesModel { Ok(StreamResult { stream: Box::pin(stream), request: Some(aimux_core::shared::RequestInfo { body: Some(body) }), - response: Some(aimux_core::result::StreamResponse { + response: Some(aimux_core::shared::StreamResponseInfo { headers: Some(response_headers), }), }) diff --git a/aimux-providers/tests/alibaba_test.rs b/aimux-providers/tests/alibaba_test.rs index 9ff1525c6..6e8f6daf8 100644 --- a/aimux-providers/tests/alibaba_test.rs +++ b/aimux-providers/tests/alibaba_test.rs @@ -371,10 +371,6 @@ async fn extracts_usage_with_reasoning_tokens() { assert_eq!(result.usage.output_tokens.text, Some(315)); } -// ════════════════════════════════════════════════════════════════════════════ -// Reasoning (top-level reasoning �?reasoning_effort, shared OpenAI behaviour) -// ════════════════════════════════════════════════════════════════════════════ - // ════════════════════════════════════════════════════════════════════════════ // doGenerate / doStream / errors // ════════════════════════════════════════════════════════════════════════════ diff --git a/aimux-providers/tests/cassette_full_test.rs b/aimux-providers/tests/cassette_full_test.rs index 458da3ea5..4feffd37c 100644 --- a/aimux-providers/tests/cassette_full_test.rs +++ b/aimux-providers/tests/cassette_full_test.rs @@ -15,7 +15,7 @@ use futures::StreamExt; use wiremock::MockServer; use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; -use aimux_core::stream_part::StreamPart; +use aimux_core::stream_part::TextStreamPart; use aimux_providers::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIProvider}; /// Run generate_text + stream_text against a cassette directory. @@ -61,8 +61,8 @@ async fn replay_openai_compat( let mut got_finish = false; while let Some(part) = stream.next().await { match part.expect("{cassette_dir}: stream part should be ok") { - StreamPart::StreamStart { .. } => got_stream_start = true, - StreamPart::Finish { .. } => got_finish = true, + TextStreamPart::StreamStart { .. } => got_stream_start = true, + TextStreamPart::Finish { .. } => got_finish = true, _ => {} } } diff --git a/aimux-providers/tests/e2e_test.rs b/aimux-providers/tests/e2e_test.rs index 4cd5a9746..4cffe5f12 100644 --- a/aimux-providers/tests/e2e_test.rs +++ b/aimux-providers/tests/e2e_test.rs @@ -10,7 +10,7 @@ use aimux_core::content::ContentPart; use aimux_core::error::AiMuxError; use aimux_core::generate::{GenerateTextOptions, generate_text, stream_text}; use aimux_core::message::{MessageContent, ModelMessage, Role}; -use aimux_core::stream_part::StreamPart; +use aimux_core::stream_part::TextStreamPart; use aimux_core::tool::{FunctionTool, Tool, ToolChoice}; use aimux_core::types::FinishReasonUnified; use aimux_providers::{ @@ -353,25 +353,24 @@ async fn e2e_openai_stream_part_sequence() { parts.push(part.expect("stream part should be ok")); } - // Expected sequence: StreamStart, TextStart, TextDelta, TextEnd, Finish - // (ResponseMetadata may or may not be present depending on implementation) + // Expected sequence: StreamStart, TextStart, TextDelta, TextEnd, FinishStep, Finish assert!( - parts.len() >= 5, - "expected at least 5 parts, got {}", + parts.len() >= 6, + "expected at least 6 parts, got {}", parts.len() ); // First part must be StreamStart - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); + assert!(matches!(parts[0], TextStreamPart::StreamStart { .. })); // Last part must be Finish let last = parts.last().unwrap(); - assert!(matches!(last, StreamPart::Finish { .. })); + assert!(matches!(last, TextStreamPart::Finish { .. })); // Must contain at least one TextDelta let has_text_delta = parts .iter() - .any(|p| matches!(p, StreamPart::TextDelta { delta, .. } if delta == "Hi")); + .any(|p| matches!(p, TextStreamPart::TextDelta { delta, .. } if delta == "Hi")); assert!(has_text_delta, "expected a TextDelta with 'Hi'"); } @@ -419,16 +418,16 @@ async fn e2e_anthropic_stream_part_sequence() { } // First part must be StreamStart - assert!(matches!(parts[0], StreamPart::StreamStart { .. })); + assert!(matches!(parts[0], TextStreamPart::StreamStart { .. })); // Last part must be Finish let last = parts.last().unwrap(); - assert!(matches!(last, StreamPart::Finish { .. })); + assert!(matches!(last, TextStreamPart::Finish { .. })); // Must contain at least one TextDelta let has_text_delta = parts .iter() - .any(|p| matches!(p, StreamPart::TextDelta { delta, .. } if delta == "Hi")); + .any(|p| matches!(p, TextStreamPart::TextDelta { delta, .. } if delta == "Hi")); assert!(has_text_delta, "expected a TextDelta with 'Hi'"); } @@ -840,10 +839,10 @@ async fn e2e_openai_stream_tool_calls() { let has_tool_part = parts.iter().any(|p| { matches!( p, - StreamPart::ToolCall(_) - | StreamPart::ToolInputStart { .. } - | StreamPart::ToolInputDelta { .. } - | StreamPart::ToolInputEnd { .. } + TextStreamPart::ToolCall(_) + | TextStreamPart::ToolInputStart { .. } + | TextStreamPart::ToolInputDelta { .. } + | TextStreamPart::ToolInputEnd { .. } ) }); assert!( @@ -852,12 +851,14 @@ async fn e2e_openai_stream_tool_calls() { ); // Must contain a Finish part - let has_finish = parts.iter().any(|p| matches!(p, StreamPart::Finish { .. })); + let has_finish = parts + .iter() + .any(|p| matches!(p, TextStreamPart::Finish { .. })); assert!(has_finish, "expected a Finish StreamPart"); // If a complete ToolCall part exists, verify its fields let tool_call = parts.iter().find_map(|p| match p { - StreamPart::ToolCall(aimux_core::tool::ToolCall { + TextStreamPart::ToolCall(aimux_core::tool::ToolCall { tool_name, input, .. }) => Some((tool_name, input)), _ => None, diff --git a/aimux-providers/tests/openai_output_round_trip_test.rs b/aimux-providers/tests/openai_output_round_trip_test.rs index 27fcfa90e..cacdfe65d 100644 --- a/aimux-providers/tests/openai_output_round_trip_test.rs +++ b/aimux-providers/tests/openai_output_round_trip_test.rs @@ -27,11 +27,11 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use aimux_core::generate::{ - GenerateTextOptions, generate_text, generate_text_as_openai, stream_text, stream_text_as_openai, + GenerateTextOptions, generate_text, generate_text_as_openai, + generate_text_result_to_chat_completion, stream_text, stream_text_as_openai, }; use aimux_core::openai_output::{ - ChatCompletionChunk, OpenAiStreamOptions, encode_chunk_sse, to_chat_completion, - to_chat_completion_stream, + ChatCompletionChunk, OpenAiStreamOptions, encode_chunk_sse, to_chat_completion_stream, }; use aimux_providers::openai::{OpenAICompatProfile, OpenAIConfig, OpenAIProvider}; @@ -108,7 +108,7 @@ async fn round_trip_text_only() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "gpt-4o"); + let completion = generate_text_result_to_chat_completion(&result, "gpt-4o"); // ── Exact field round-trip ── assert_eq!(completion.id, "chatcmpl-test-001"); @@ -166,7 +166,7 @@ async fn round_trip_with_tool_calls() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "gpt-4o"); + let completion = generate_text_result_to_chat_completion(&result, "gpt-4o"); // content should be null (tool_calls only). assert_eq!(completion.choices[0].message.content, None); @@ -228,7 +228,7 @@ async fn round_trip_with_reasoning() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "gpt-4o"); + let completion = generate_text_result_to_chat_completion(&result, "gpt-4o"); assert_eq!( completion.choices[0].message.content.as_deref(), @@ -282,7 +282,7 @@ async fn round_trip_with_cached_tokens() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "gpt-4o"); + let completion = generate_text_result_to_chat_completion(&result, "gpt-4o"); // KV cache token should round-trip — this is the critical assertion for // cache-hit preservation across the conversion. @@ -334,7 +334,7 @@ async fn round_trip_multiple_tool_calls() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "gpt-4o"); + let completion = generate_text_result_to_chat_completion(&result, "gpt-4o"); let tool_calls = completion.choices[0] .message @@ -373,7 +373,7 @@ async fn cross_protocol_anthropic_to_openai_non_streaming() { .await .expect("anthropic generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "claude-sonnet-4-6"); + let completion = generate_text_result_to_chat_completion(&result, "claude-sonnet-4-6"); // ── Validate OpenAI structure ── assert_eq!(completion.object, "chat.completion"); @@ -853,7 +853,7 @@ async fn cross_protocol_deepseek_with_reasoning() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "deepseek-chat"); + let completion = generate_text_result_to_chat_completion(&result, "deepseek-chat"); assert_eq!(completion.object, "chat.completion"); assert_eq!( @@ -909,7 +909,7 @@ async fn kv_cache_tokens_preserved_with_cache_write() { .await .expect("generate_text should succeed"); - let completion = to_chat_completion(&result.raw, "gpt-4o"); + let completion = generate_text_result_to_chat_completion(&result, "gpt-4o"); let details = completion .usage diff --git a/bindings/flutter/lib/errors.dart b/bindings/flutter/lib/errors.dart index 75b9d9e3e..f9f6cab81 100644 --- a/bindings/flutter/lib/errors.dart +++ b/bindings/flutter/lib/errors.dart @@ -4,7 +4,7 @@ // Transport (aimux-error.h): every fallible C call returns // `aimux_error_t *` — NULL on success (the result is in the trailing // out-param), non-NULL on failure (out-param at its sentinel: 0 / NULL). The -// unified code selects AiMuxError (1..17), RecordingError (100..105), or a +// unified code selects AiMuxError (1..18), RecordingError (100..105), or a // C ABI failure (200..206). The last range maps to // StateError('aimux ffi: …'); Dart does not expose seven additional classes. // Every field is copied before the error is released with `aimux_error_free` @@ -22,7 +22,7 @@ import 'package:ffi/ffi.dart'; // ───────────────────────────────────────────────────────────────────────────── /// Machine-readable codes. Values match C `aimux_error_code_t` / Go `Code`. -/// 16 variant codes: 1–17 (1 is the catch-all; 4 is retired). A code outside +/// 17 variant codes: 1–18 (1 is the catch-all; 4 is retired). A code outside /// that set is a header/library mismatch and fails with [StateError], not an /// error type. Every HTTP-shaped failure /// arrives as [apiCall], classified @@ -45,6 +45,7 @@ abstract final class AimuxErrorCode { static const int noSuchTool = 15; static const int invalidToolInput = 16; static const int toolCallRepair = 17; + static const int noOutputGenerated = 18; static const int other = 1; static const int retry = 14; @@ -64,6 +65,7 @@ abstract final class AimuxErrorCode { noSuchTool: 'NoSuchTool', invalidToolInput: 'InvalidToolInput', toolCallRepair: 'ToolCallRepair', + noOutputGenerated: 'NoOutputGenerated', other: 'Other', retry: 'Retry', }; @@ -78,7 +80,7 @@ abstract final class AimuxErrorCode { /// Decode the `aimux_error_t *` [e] returned by a call that can fail in /// `AiMuxError` (`[AiMuxError]` in aimux-ffi.h). NULL → returns (success). -/// Codes 1..17 become [AimuxException]; 200..206 become [StateError]. +/// Codes 1..18 become [AimuxException]; 200..206 become [StateError]. /// The returned error is always freed. void expectAimuxError(Pointer e, String context) { if (e == nullptr) return; @@ -513,7 +515,7 @@ class AimuxException implements Exception { /// Build the typed subclass from a returned `const aimux_error_t *` [error] /// via the `aimux_error_*` getters (payload getters only under the owning /// code; getter strings freed here). The caller ([expectAimuxError]) - /// frees it. A code outside 1..17 is a + /// frees it. A code outside 1..18 is a /// contract violation and throws [StateError]. factory AimuxException._decode(Pointer error, String context) { final code = _errorCode(error); @@ -627,6 +629,8 @@ class AimuxException implements Exception { return InvalidToolInputError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.toolCallRepair: return ToolCallRepairError(message, status: status, retryMs: retryMs, retryable: retryable); + case AimuxErrorCode.noOutputGenerated: + return NoOutputGeneratedError(message, status: status, retryMs: retryMs, retryable: retryable); case AimuxErrorCode.other: return OtherError(message, status: status, retryMs: retryMs, retryable: retryable); default: @@ -643,6 +647,11 @@ class AimuxException implements Exception { // ── Concrete subclasses (one per live AimuxErrorCode) ─────────────────────── +class NoOutputGeneratedError extends AimuxException { + NoOutputGeneratedError(super.message, {super.status, super.retryMs, super.retryable}) + : super(code: AimuxErrorCode.noOutputGenerated); +} + /// JSON parse / serialize failure. class JSONParseError extends AimuxException { JSONParseError(super.message, {super.status, super.retryMs, super.retryable}) diff --git a/bindings/flutter/lib/types.dart b/bindings/flutter/lib/types.dart index 07fe1e7df..bf1e15391 100644 --- a/bindings/flutter/lib/types.dart +++ b/bindings/flutter/lib/types.dart @@ -414,15 +414,22 @@ class ResponseMetadata { Map toJson() => _$ResponseMetadataToJson(this); } +class GenerateRequest { + final dynamic body; + GenerateRequest({this.body}); + factory GenerateRequest.fromJson(Map json) => GenerateRequest(body: json['body']); + Map toJson() => {'body': body}; +} + class GenerateResponse { final String? id; final String? timestamp; final String? modelId; - final dynamic body; final Map? headers; - GenerateResponse({this.id, this.timestamp, this.modelId, this.body, this.headers}); - factory GenerateResponse.fromJson(Map json) => GenerateResponse(id: json['id'] as String?, timestamp: json['timestamp'] as String?, modelId: json['model_id'] as String?, body: json['body'], headers: (json['headers'] as Map?)?.map((key, value) => MapEntry(key, value as String))); - Map toJson() => {'id': id, 'timestamp': timestamp, 'model_id': modelId, if (body != null) 'body': body, if (headers != null) 'headers': headers}; + final dynamic body; + GenerateResponse({this.id, this.timestamp, this.modelId, this.headers, this.body}); + factory GenerateResponse.fromJson(Map json) => GenerateResponse(id: json['id'] as String?, timestamp: json['timestamp'] as String?, modelId: json['model_id'] as String?, headers: (json['headers'] as Map?)?.cast(), body: json['body']); + Map toJson() => {if (id != null) 'id': id, 'timestamp': timestamp, 'model_id': modelId, 'headers': headers, 'body': body}; } /// A content item in a `GenerateResult`. Mirrors `GenerateContent.ts` @@ -775,9 +782,8 @@ class GenerateResult { final List> warnings; final Map? providerMetadata; final GenerateResponse? response; - final Map? requestBody; - final Map? responseHeaders; - GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, this.response, this.requestBody, this.responseHeaders}); + final GenerateRequest? request; + GenerateResult({required this.content, required this.finishReason, required this.usage, this.warnings = const [], this.providerMetadata, this.response, this.request}); factory GenerateResult.fromJson(Map json) => GenerateResult( content: (json['content'] as List? ?? []).map((e) => GenerateContent.fromJson(e as Map)).toList(), finishReason: FinishReason.fromJson(json['finish_reason'] as Map), @@ -785,10 +791,9 @@ class GenerateResult { warnings: (json['warnings'] as List? ?? []).map((e) => e as Map).toList(), providerMetadata: json['provider_metadata'] as Map?, response: json['response'] == null ? null : GenerateResponse.fromJson(json['response'] as Map), - requestBody: json['request_body'] as Map?, - responseHeaders: json['response_headers'] as Map?, + request: json['request'] == null ? null : GenerateRequest.fromJson(json['request'] as Map), ); - Map toJson() => {'content': content.map((c) => c.toJson()).toList(), 'finish_reason': finishReason.toJson(), 'usage': usage.toJson(), 'warnings': warnings, 'response': response?.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata, if (requestBody != null) 'request_body': requestBody, if (responseHeaders != null) 'response_headers': responseHeaders}; + Map toJson() => {'content': content.map((c) => c.toJson()).toList(), 'finish_reason': finishReason.toJson(), 'usage': usage.toJson(), 'warnings': warnings, 'response': response?.toJson(), if (providerMetadata != null) 'provider_metadata': providerMetadata, 'request': request?.toJson()}; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/bindings/go/error.go b/bindings/go/error.go index ac858cbb2..a92904e03 100644 --- a/bindings/go/error.go +++ b/bindings/go/error.go @@ -10,7 +10,7 @@ import ( ) // Code is the machine-readable Aimux error code. Values match -// aimux-ffi aimux_error_code_t (1..3, 5..17; 4 is retired). Retry is 14; +// aimux-ffi aimux_error_code_t (1..3, 5..18; 4 is retired). Retry is 14; // tool-call errors occupy 15..17. A code outside that set is a header/library // mismatch and expectAimuxError // panics rather than inventing an "unknown" variant. Recording failures are @@ -41,6 +41,7 @@ const ( CodeNoSuchTool Code = 15 CodeInvalidToolInput Code = 16 CodeToolCallRepair Code = 17 + CodeNoOutputGenerated Code = 18 ) // String returns the core error_type name (e.g. "ApiCall", "TokenExpired"). @@ -77,6 +78,8 @@ func (c Code) String() string { return "InvalidToolInput" case CodeToolCallRepair: return "ToolCallRepair" + case CodeNoOutputGenerated: + return "NoOutputGenerated" case CodeOther: return "Other" case CodeRetry: @@ -86,10 +89,10 @@ func (c Code) String() string { } } -// codeFromC maps a C aimux_error_code_t (1..3, 5..17); false for any other +// codeFromC maps a C aimux_error_code_t (1..3, 5..18); false for any other // value, including retired code 4. func codeFromC(code int) (Code, bool) { - if code >= int(CodeOther) && code <= int(CodeToolCallRepair) && code != 4 { + if code >= int(CodeOther) && code <= int(CodeNoOutputGenerated) && code != 4 { return Code(code), true } return 0, false diff --git a/bindings/go/types.go b/bindings/go/types.go index 845c2fa40..9c70586fd 100644 --- a/bindings/go/types.go +++ b/bindings/go/types.go @@ -211,11 +211,16 @@ type ResponseMetadata struct { ModelID *string `json:"model_id,omitempty"` } -// GenerateResponseMetadata is response metadata with optional HTTP details. +// GenerateRequest contains the provider HTTP request body. +type GenerateRequest struct { + Body json.RawMessage `json:"body,omitempty"` +} + +// GenerateResponseMetadata is response metadata with optional headers and body. type GenerateResponseMetadata struct { ResponseMetadata - Body json.RawMessage `json:"body,omitempty"` Headers map[string]string `json:"headers,omitempty"` + Body json.RawMessage `json:"body,omitempty"` } // GenerateResult is the raw provider result. @@ -227,8 +232,7 @@ type GenerateResult struct { Warnings []json.RawMessage `json:"warnings,omitempty"` ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"` Response *GenerateResponseMetadata `json:"response,omitempty"` - RequestBody json.RawMessage `json:"request_body,omitempty"` - ResponseHeaders map[string]string `json:"response_headers,omitempty"` + Request *GenerateRequest `json:"request,omitempty"` } // GenerateTextResult is the typed result of a GenerateText call. diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java index 727fecbe8..c73ca92ff 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxException.java @@ -11,7 +11,7 @@ /** * AiMuxError hierarchy (OpenAI Java / Vercel AI SDK style). * - *

Raised when a fallible C ABI call returns an AiMuxError code (1–17). + *

Raised when a fallible C ABI call returns an AiMuxError code (1–18). * Recording failures use the * independent {@link RecordingException} type; C ABI failures (bad raw * wire JSON, use-after-close, re-entrant call) surface as plain @@ -29,7 +29,7 @@ * } * } * - *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} 1–17), + *

Every instance carries {@link #getCode()} (C {@code aimux_error_code_t} 1–18), * {@link #getStatusCode()} (HTTP or {@code -1}), {@link #getRetryMs()} (hint * or {@code -1}; {@code 0} = retry now) and {@link #isRetryable()}. Message * text comes from the C layer. @@ -42,7 +42,7 @@ public class AimuxException extends RuntimeException { private static final long serialVersionUID = 1L; // ── aimux_error_code_t (aimux-error.h) ────────────────────────────────── - // 16 variant codes (1–17; 1 is the catch-all OTHER; 4 is retired — the + // 17 variant codes (1–18; 1 is the catch-all OTHER; 4 is retired — the // legacy Tool variant; 14 = RETRY); every HTTP-shaped failure // arrives as AIMUX_E_API_CALL. A code outside that set is a header/library // mismatch and fails with IllegalStateException, never an AimuxException. @@ -63,6 +63,7 @@ public class AimuxException extends RuntimeException { public static final int AIMUX_E_NO_SUCH_TOOL = 15; public static final int AIMUX_E_INVALID_TOOL_INPUT = 16; public static final int AIMUX_E_TOOL_CALL_REPAIR = 17; + public static final int AIMUX_E_NO_OUTPUT_GENERATED = 18; public static final int AIMUX_E_OTHER = 1; public static final int AIMUX_E_RETRY = 14; @@ -103,7 +104,7 @@ public AimuxException(String message, int code, int status, long retryMs, Throwa // ── Accessors ─────────────────────────────────────────────────────────── - /** C {@code aimux_error_code_t} value (1–17). */ + /** C {@code aimux_error_code_t} value (1–18). */ public int getCode() { return code; } @@ -140,7 +141,7 @@ public boolean isRetryable() { * every returned string. Does not own the pointer: the caller * ({@link AimuxResult#expectAimuxError}) frees the returned error afterwards * (retry attempt errors are new owned copies and are freed here). - * A code outside 1–17 is a header/library mismatch → + * A code outside 1–18 is a header/library mismatch → * {@link IllegalStateException}. */ static AimuxException fromC(Pointer error, String prefix) { @@ -325,6 +326,8 @@ private static AimuxException createByCode(int code, String message, int status, return new InvalidToolInputError(message, status, retryMs); case AIMUX_E_TOOL_CALL_REPAIR: return new ToolCallRepairError(message, status, retryMs); + case AIMUX_E_NO_OUTPUT_GENERATED: + return new NoOutputGeneratedError(message, status, retryMs); case AIMUX_E_OTHER: return new OtherError(message, status, retryMs); default: @@ -365,6 +368,8 @@ public static String codeName(int code) { return "InvalidToolInput"; case AIMUX_E_TOOL_CALL_REPAIR: return "ToolCallRepair"; + case AIMUX_E_NO_OUTPUT_GENERATED: + return "NoOutputGenerated"; case AIMUX_E_OTHER: return "Other"; case AIMUX_E_RETRY: @@ -376,6 +381,12 @@ public static String codeName(int code) { // ── Subclasses (mirror Node error.ts / Python hierarchy) ──────────────── + public static class NoOutputGeneratedError extends AimuxException { + public NoOutputGeneratedError(String message, int status, long retryMs) { + super(message, AIMUX_E_NO_OUTPUT_GENERATED, status, retryMs); + } + } + public static class JSONParseError extends AimuxException { public JSONParseError(String message, int status, long retryMs) { super(message, AIMUX_E_JSON_PARSE, status, retryMs); diff --git a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java index 2c2b545b8..5ff3b786f 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/AimuxResult.java @@ -9,7 +9,7 @@ * *

Every fallible C call returns an {@code aimux_error_t *} ({@code null} * = success, result in the out-parameter). Its code identifies an AiMuxError - * (1–17), RecordingError (100–105), or a failure detected by the C ABI + * (1–18), RecordingError (100–105), or a failure detected by the C ABI * (200–206). The last range collapses to {@link IllegalStateException} * ({@code "aimux ffi: "} + message); Java does not expose seven additional * exception types. Each helper frees the pointer exactly once. User-triggerable @@ -64,7 +64,7 @@ private static String prefix(String context) { } /** - * Decode an error from a call that may return {@code AiMuxError}: 1–17 → + * Decode an error from a call that may return {@code AiMuxError}: 1–18 → * {@link AimuxException}; 200–206 → {@link IllegalStateException}. * Frees {@code e}. */ @@ -81,7 +81,7 @@ static RuntimeException expectAimuxError(Pointer e, String context) { } // Code 4 is retired; Retry is 14 and tool errors are 15..17. if (code < AimuxException.AIMUX_E_OTHER - || code > AimuxException.AIMUX_E_TOOL_CALL_REPAIR + || code > AimuxException.AIMUX_E_NO_OUTPUT_GENERATED || code == 4) { return codeMismatch(code, prefix, "AiMuxError"); } diff --git a/bindings/java/src/main/java/ai/arcships/aimux/Types.java b/bindings/java/src/main/java/ai/arcships/aimux/Types.java index f2eb8f775..1ed7c68a3 100644 --- a/bindings/java/src/main/java/ai/arcships/aimux/Types.java +++ b/bindings/java/src/main/java/ai/arcships/aimux/Types.java @@ -384,67 +384,6 @@ public int hashCode() { } } - /** Response metadata and HTTP details from a generation call. */ - public static class GenerateResponse { - @JsonProperty("id") private String id; - @JsonProperty("timestamp") private String timestamp; - @JsonProperty("model_id") private String modelId; - @JsonProperty("headers") private Map headers; - @JsonProperty("body") private JsonNode body; - - @JsonCreator - GenerateResponse() {} - - private GenerateResponse(String id, String timestamp, String modelId, Map headers, JsonNode body) { - this.id = id; - this.timestamp = timestamp; - this.modelId = modelId; - this.headers = headers; - this.body = body; - } - - public String getId() { return id; } - public String getTimestamp() { return timestamp; } - public String getModelId() { return modelId; } - public Map getHeaders() { return headers; } - public JsonNode getBody() { return body; } - - public static Builder builder() { return new Builder(); } - - public static class Builder { - private String id; - private String timestamp; - private String modelId; - private Map headers; - private JsonNode body; - - public Builder id(String v) { this.id = v; return this; } - public Builder timestamp(String v) { this.timestamp = v; return this; } - public Builder modelId(String v) { this.modelId = v; return this; } - public Builder headers(Map v) { this.headers = v; return this; } - public Builder body(JsonNode v) { this.body = v; return this; } - - public GenerateResponse build() { return new GenerateResponse(id, timestamp, modelId, headers, body); } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof GenerateResponse)) return false; - GenerateResponse that = (GenerateResponse) o; - return Objects.equals(id, that.id) - && Objects.equals(timestamp, that.timestamp) - && Objects.equals(modelId, that.modelId) - && Objects.equals(headers, that.headers) - && Objects.equals(body, that.body); - } - - @Override - public int hashCode() { - return Objects.hash(id, timestamp, modelId, headers, body); - } - } - /** * A tool call requested by the model. * @@ -3081,6 +3020,86 @@ public GenerateContent deserialize(JsonParser p, DeserializationContext ctxt) th } } + /** Provider HTTP request information. */ + public static class GenerateRequest { + @JsonProperty("body") private JsonNode body; + + @JsonCreator + GenerateRequest() {} + + private GenerateRequest(JsonNode body) { this.body = body; } + public JsonNode getBody() { return body; } + public static Builder builder() { return new Builder(); } + + public static class Builder { + private JsonNode body; + public Builder body(JsonNode v) { this.body = v; return this; } + public GenerateRequest build() { return new GenerateRequest(body); } + } + + @Override + public boolean equals(Object o) { + return o instanceof GenerateRequest && Objects.equals(body, ((GenerateRequest) o).body); + } + + @Override + public int hashCode() { return Objects.hash(body); } + } + + /** Provider response metadata, headers, and body. */ + public static class GenerateResponse { + @JsonProperty("id") private String id; + @JsonProperty("timestamp") private String timestamp; + @JsonProperty("model_id") private String modelId; + @JsonProperty("headers") private Map headers; + @JsonProperty("body") private JsonNode body; + + @JsonCreator + GenerateResponse() {} + + private GenerateResponse(String id, String timestamp, String modelId, + Map headers, JsonNode body) { + this.id = id; + this.timestamp = timestamp; + this.modelId = modelId; + this.headers = headers; + this.body = body; + } + + public String getId() { return id; } + public String getTimestamp() { return timestamp; } + public String getModelId() { return modelId; } + public Map getHeaders() { return headers; } + public JsonNode getBody() { return body; } + public static Builder builder() { return new Builder(); } + + public static class Builder { + private String id; + private String timestamp; + private String modelId; + private Map headers; + private JsonNode body; + public Builder id(String v) { this.id = v; return this; } + public Builder timestamp(String v) { this.timestamp = v; return this; } + public Builder modelId(String v) { this.modelId = v; return this; } + public Builder headers(Map v) { this.headers = v; return this; } + public Builder body(JsonNode v) { this.body = v; return this; } + public GenerateResponse build() { return new GenerateResponse(id, timestamp, modelId, headers, body); } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof GenerateResponse)) return false; + GenerateResponse that = (GenerateResponse) o; + return Objects.equals(id, that.id) && Objects.equals(timestamp, that.timestamp) + && Objects.equals(modelId, that.modelId) && Objects.equals(headers, that.headers) + && Objects.equals(body, that.body); + } + + @Override + public int hashCode() { return Objects.hash(id, timestamp, modelId, headers, body); } + } + /** * Result of `LanguageModel::do_generate` (non-streaming) — the raw provider * result surfaced via {@link GenerateTextResult#getRaw()}. @@ -3094,24 +3113,22 @@ public static class GenerateResult { @JsonProperty("usage") private Usage usage = new Usage(); @JsonProperty("warnings") private List warnings = new ArrayList<>(); @JsonProperty("provider_metadata") private JsonNode providerMetadata; - @JsonProperty("response") private GenerateResponse response = new GenerateResponse(); - @JsonProperty("request_body") private JsonNode requestBody; - @JsonProperty("response_headers") private Map responseHeaders; + @JsonProperty("response") private GenerateResponse response; + @JsonProperty("request") private GenerateRequest request; @JsonCreator GenerateResult() {} private GenerateResult(List content, FinishReason finishReason, Usage usage, List warnings, JsonNode providerMetadata, GenerateResponse response, - JsonNode requestBody, Map responseHeaders) { + GenerateRequest request) { this.content = content; this.finishReason = finishReason; this.usage = usage; this.warnings = warnings; this.providerMetadata = providerMetadata; this.response = response; - this.requestBody = requestBody; - this.responseHeaders = responseHeaders; + this.request = request; } public List getContent() { return content; } @@ -3120,8 +3137,7 @@ private GenerateResult(List content, FinishReason finishReason, public List getWarnings() { return warnings; } public JsonNode getProviderMetadata() { return providerMetadata; } public GenerateResponse getResponse() { return response; } - public JsonNode getRequestBody() { return requestBody; } - public Map getResponseHeaders() { return responseHeaders; } + public GenerateRequest getRequest() { return request; } /** Names of the variant tags present in {@link #getContent()} (e.g. "Text", "ToolCall"). */ public List getContentVariantTags() { @@ -3148,9 +3164,8 @@ public static class Builder { private Usage usage = new Usage(); private List warnings = new ArrayList<>(); private JsonNode providerMetadata; - private GenerateResponse response = new GenerateResponse(); - private JsonNode requestBody; - private Map responseHeaders; + private GenerateResponse response; + private GenerateRequest request; public Builder content(List v) { this.content = v; return this; } public Builder finishReason(FinishReason v) { this.finishReason = v; return this; } @@ -3158,12 +3173,11 @@ public static class Builder { public Builder warnings(List v) { this.warnings = v; return this; } public Builder providerMetadata(JsonNode v) { this.providerMetadata = v; return this; } public Builder response(GenerateResponse v) { this.response = v; return this; } - public Builder requestBody(JsonNode v) { this.requestBody = v; return this; } - public Builder responseHeaders(Map v) { this.responseHeaders = v; return this; } + public Builder request(GenerateRequest v) { this.request = v; return this; } public GenerateResult build() { return new GenerateResult(content, finishReason, usage, warnings, providerMetadata, response, - requestBody, responseHeaders); + request); } } @@ -3178,14 +3192,12 @@ public boolean equals(Object o) { && Objects.equals(warnings, that.warnings) && Objects.equals(providerMetadata, that.providerMetadata) && Objects.equals(response, that.response) - && Objects.equals(requestBody, that.requestBody) - && Objects.equals(responseHeaders, that.responseHeaders); + && Objects.equals(request, that.request); } @Override public int hashCode() { - return Objects.hash(content, finishReason, usage, warnings, providerMetadata, response, requestBody, - responseHeaders); + return Objects.hash(content, finishReason, usage, warnings, providerMetadata, response, request); } } diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt index 4c18eaed5..353be46dd 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Errors.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.JsonPrimitive /** * Machine-readable codes matching aimux-ffi `aimux_error_code_t` (aimux-error.h). - * 1..17 mirror the 16 core variants (1 is the catch-all `Other`; + * 1..18 mirror the 17 core variants (1 is the catch-all `Other`; * 4 is retired — the legacy `Tool` variant; 14 = `Retry`). The * per-status codes (Provider, Http, RateLimited, Auth, ModelNotFound) are * gone, every HTTP-shaped failure arrives as [AIMUX_E_API_CALL]. @@ -30,6 +30,7 @@ const val AIMUX_E_ABORTED: Int = 13 const val AIMUX_E_NO_SUCH_TOOL: Int = 15 const val AIMUX_E_INVALID_TOOL_INPUT: Int = 16 const val AIMUX_E_TOOL_CALL_REPAIR: Int = 17 +const val AIMUX_E_NO_OUTPUT_GENERATED: Int = 18 const val AIMUX_E_OTHER: Int = 1 const val AIMUX_E_RETRY: Int = 14 @@ -55,7 +56,7 @@ const val AIMUX_E_RETRY: Int = 14 * } * ``` * - * Transport: Rust → C `aimux_error_t *` with code 1..17 → [fromC]. + * Transport: Rust → C `aimux_error_t *` with code 1..18 → [fromC]. * Primary path is not a JSON * error envelope. */ @@ -86,7 +87,7 @@ sealed class AimuxException( * string. Does not own the pointer: the caller ([expectAimuxError]) frees * the returned error afterwards (retry attempt errors are new owned * copies and are freed here). Code [AIMUX_OK] or a code outside - * 1..17 is a header/library mismatch and throws [IllegalStateException]. + * 1..18 is a header/library mismatch and throws [IllegalStateException]. */ @JvmStatic internal fun fromC(error: Pointer, prefix: String = ""): AimuxException { @@ -189,7 +190,7 @@ sealed class AimuxException( } /** - * Build the subclass for a core / C error code (1..17). + * Build the subclass for a core / C error code (1..18). * * Any other code — [AIMUX_OK] on a failure path or a code this binding * does not know — is a header/library mismatch and throws @@ -251,6 +252,7 @@ sealed class AimuxException( AIMUX_E_NO_SUCH_TOOL -> NoSuchToolError(message, status, retryMs, cause, retryable, toolName, availableTools) AIMUX_E_INVALID_TOOL_INPUT -> InvalidToolInputError(message, status, retryMs, cause, retryable, toolName, toolInput) AIMUX_E_TOOL_CALL_REPAIR -> ToolCallRepairError(message, status, retryMs, cause, retryable, originalError) + AIMUX_E_NO_OUTPUT_GENERATED -> NoOutputGeneratedError(message, status, retryMs, cause, retryable) AIMUX_E_OTHER -> OtherError(message, status, retryMs, cause, retryable) else -> throw IllegalStateException("Unknown aimux_error_code_t: $code") } @@ -273,6 +275,7 @@ sealed class AimuxException( AIMUX_E_NO_SUCH_TOOL -> "NoSuchTool" AIMUX_E_INVALID_TOOL_INPUT -> "InvalidToolInput" AIMUX_E_TOOL_CALL_REPAIR -> "ToolCallRepair" + AIMUX_E_NO_OUTPUT_GENERATED -> "NoOutputGenerated" AIMUX_E_OTHER -> "Other" AIMUX_E_RETRY -> "Retry" else -> "Code($code)" @@ -280,6 +283,14 @@ sealed class AimuxException( } } +class NoOutputGeneratedError( + message: String, + status: Int = -1, + retryMs: Long = -1, + cause: Throwable? = null, + retryable: Boolean = false, +) : AimuxException(message, AIMUX_E_NO_OUTPUT_GENERATED, status, retryMs, cause, retryable) + class JSONParseError( message: String, status: Int = -1, diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt index 715ca1a95..39bcb9860 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Model.kt @@ -8,7 +8,7 @@ * Errors: every fallible C call returns an `aimux_error_t *` ([Pointer]?): * null = success, result written to the trailing out-parameter * ([LongByReference] for handles, [PointerByReference] for JSON strings); - * non-null = failure. Its unified code identifies [AimuxException] (1..17), + * non-null = failure. Its unified code identifies [AimuxException] (1..18), * [RecordingException] (100..105), or a C ABI failure (200..206). The last * range maps to `IllegalStateException("aimux ffi: …")`. A decoder releases * the returned pointer with `aimux_error_free`. @@ -290,7 +290,7 @@ private fun ffiError(e: Pointer, prefix: String): IllegalStateException { } /** - * Decode an error from a call that may return `AiMuxError`: 1..17 → + * Decode an error from a call that may return `AiMuxError`: 1..18 → * [AimuxException]; 200..206 → [IllegalStateException]. Frees [e]. */ internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeException { @@ -299,7 +299,7 @@ internal fun expectAimuxError(e: Pointer, context: String = ""): RuntimeExceptio val code = FFI.lib.aimux_error_code(e) if (isFfiCode(code)) return ffiError(e, prefix) // Code 4 is retired; Retry is 14 and tool errors are 15..17. - check(code in AIMUX_E_OTHER..AIMUX_E_TOOL_CALL_REPAIR && code != 4) { + check(code in AIMUX_E_OTHER..AIMUX_E_NO_OUTPUT_GENERATED && code != 4) { "${prefix}aimux ffi: expected AiMuxError code, got $code" } return AimuxException.fromC(e, prefix) diff --git a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt index 2ece2846d..a8ec46957 100644 --- a/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt +++ b/bindings/kotlin/src/main/kotlin/ai/arcships/aimux/Types.kt @@ -145,13 +145,18 @@ data class ResponseMetadata( @SerialName("model_id") val modelId: String? = null, ) +@Serializable +data class GenerateRequest( + val body: JsonElement? = null, +) + @Serializable data class GenerateResponse( val id: String? = null, val timestamp: String? = null, @SerialName("model_id") val modelId: String? = null, - val body: JsonElement? = null, val headers: Map? = null, + val body: JsonElement? = null, ) /** @@ -1051,8 +1056,7 @@ data class GenerateResult( val warnings: List = emptyList(), @SerialName("provider_metadata") val providerMetadata: JsonElement? = null, val response: GenerateResponse? = null, - @SerialName("request_body") val requestBody: JsonElement? = null, - @SerialName("response_headers") val responseHeaders: Map? = null, + val request: GenerateRequest? = null, ) { /** Names of the variant tags present in [content] (e.g. "Text", "ToolCall"). */ val contentVariantTags: List diff --git a/bindings/node/src/error.rs b/bindings/node/src/error.rs index 4585d05fc..229f1bc00 100644 --- a/bindings/node/src/error.rs +++ b/bindings/node/src/error.rs @@ -19,6 +19,7 @@ const ERROR_CLASS_NAMES: &[&str] = &[ "RetryError", "JSONParseError", "InvalidResponseDataError", + "NoOutputGeneratedError", "NoSuchToolError", "InvalidToolInputError", "ToolCallRepairError", @@ -177,6 +178,7 @@ fn aimux_error_class_name(error: &AiMuxError) -> &'static str { AiMuxError::ApiCall(_) => "APICallError", AiMuxError::Retry(_) => "RetryError", AiMuxError::JsonParse(_) => "JSONParseError", + AiMuxError::NoOutputGenerated(_) => "NoOutputGeneratedError", AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { "InvalidResponseDataError" } diff --git a/bindings/node/src/error.ts b/bindings/node/src/error.ts index 9fbf11051..74ff79358 100644 --- a/bindings/node/src/error.ts +++ b/bindings/node/src/error.ts @@ -78,6 +78,7 @@ export class RetryError extends AimuxError { export class JSONParseError extends AimuxError {} export class InvalidResponseDataError extends AimuxError {} +export class NoOutputGeneratedError extends AimuxError {} /** The model called a tool that was not provided (AI SDK `NoSuchToolError`). */ export class NoSuchToolError extends AimuxError { /** The tool name the model tried to call. */ diff --git a/bindings/node/src/index.ts b/bindings/node/src/index.ts index 6d4be8c05..833e33ef1 100644 --- a/bindings/node/src/index.ts +++ b/bindings/node/src/index.ts @@ -30,6 +30,7 @@ import type { GenerateTextOptions, GenerateTextResult, StreamPart, + TextStreamPart, ModelMessage, Tool, ToolChoice, @@ -82,6 +83,7 @@ export { type RetryErrorReason, JSONParseError, InvalidResponseDataError, + NoOutputGeneratedError, NoSuchToolError, InvalidToolInputError, ToolCallRepairError, @@ -146,6 +148,7 @@ export type { GenerateTextOptions, GenerateTextResult, StreamPart, + TextStreamPart, ModelMessage, Tool, ToolChoice, @@ -386,7 +389,7 @@ export async function generateText( } /** - * Stream text from a model. Yields typed {@link StreamPart}s. + * Stream text from a model. Yields typed {@link TextStreamPart}s. * * @param model - A raw model instance from `openai()`, `anthropic()`, etc. * @param prompt - A plain string or an array of typed chat messages. @@ -395,7 +398,7 @@ export async function generateText( * * Internally drives the raw `model.streamText(JSON.stringify(prompt), …)` * async generator and `JSON.parse`s each JSON-string chunk before yielding it - * as a typed `StreamPart`. + * as a typed `TextStreamPart`. * * @example * ```ts @@ -411,14 +414,14 @@ export async function* streamText( prompt: string | ModelMessage[], options?: GenerateTextOptionsWithRepair, signal?: AbortSignal, -): AsyncGenerator { +): AsyncGenerator { const promptJson = JSON.stringify(prompt) const optsJson = options ? JSON.stringify(options) : undefined const bridge = signal ? new AbortBridge(signal) : undefined const repair = options?.repairToolCall const gen = await model.streamText(promptJson, optsJson, bridge) for await (const json of gen) { - const part = JSON.parse(json) as StreamPart + const part = JSON.parse(json) as TextStreamPart // Only the settled ToolCall part is repairable; ToolInputDelta parts are // the provider's raw text and pass through immediately (AI SDK behaviour). if (repair && 'ToolCall' in part && part.ToolCall.invalid === true) { @@ -427,7 +430,7 @@ export async function* streamText( yield { ToolCall: JSON.parse( applyToolCallRepair(JSON.stringify(part.ToolCall), optsJson, reply), - ) as Extract['ToolCall'], + ) as Extract['ToolCall'], } continue } diff --git a/bindings/node/src/types.ts b/bindings/node/src/types.ts index f4d5e3396..819884884 100644 --- a/bindings/node/src/types.ts +++ b/bindings/node/src/types.ts @@ -54,7 +54,6 @@ export type * from './types/FunctionTool' export type * from './types/FunctionToolInputExample' export type * from './types/GenerateContent' export type * from './types/GenerateObjectResult' -export type * from './types/GenerateResponse' export type * from './types/GenerateResult' export type * from './types/GenerateTextOptions' export type * from './types/GenerateTextResult' @@ -137,10 +136,11 @@ export type * from './types/SpeechRequest' export type * from './types/SpeechResponse' export type * from './types/SpeechResult' export type * from './types/StreamPart' -export type * from './types/StreamResponse' +export type * from './types/StreamResponseInfo' export type * from './types/StreamTextResultAggregated' export type * from './types/TextContent' export type * from './types/TextPart' +export type * from './types/TextStreamPart' export type * from './types/TimeoutConfiguration' export type * from './types/TimingRecord' export type * from './types/Tool' diff --git a/bindings/node/src/types/AiMuxError.ts b/bindings/node/src/types/AiMuxError.ts index d542f7de5..e41afd40f 100644 --- a/bindings/node/src/types/AiMuxError.ts +++ b/bindings/node/src/types/AiMuxError.ts @@ -9,12 +9,12 @@ import type { RetryError } from "./RetryError"; * failure came from. `ApiCallError` is boxed only to keep the Rust enum * compact; serde and every binding still observe the same object shape. */ -export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "InvalidResponseData": string } | { "ToolCallNotFoundForApproval": { tool_call_id: string, approval_id: string, } } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, +export type AiMuxError = { "ApiCall": ApiCallError } | { "Retry": RetryError } | { "JsonParse": string } | { "NoOutputGenerated": string } | { "InvalidResponseData": string } | { "ToolCallNotFoundForApproval": { tool_call_id: string, approval_id: string, } } | { "NoSuchTool": { tool_name: string, available_tools?: Array | null, /** * Original argument text, when supplied by the provider. Absent in * older serialized errors and errors constructed without a call. */ -tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, +tool_input?: string | null, } } | { "InvalidToolInput": { tool_name: string, tool_input: string, cause: string, } } | { "ToolCallRepair": { original_error: AiMuxError, cause: AiMuxError, } } | { "InvalidArgument": string } | { "InvalidPrompt": string } | { "TokenExpired": string } | { "UnsupportedFunctionality": string } | { "NoSuchModel": { model_id: string, /** * What kind of model was requested (`"languageModel"`, * `"imageModel"`, …), the AI SDK's `modelType`. diff --git a/bindings/node/src/types/GenerateResponse.ts b/bindings/node/src/types/GenerateResponse.ts deleted file mode 100644 index f6d04aab2..000000000 --- a/bindings/node/src/types/GenerateResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { JsonValue } from "./serde_json/JsonValue"; - -/** - * Response metadata and HTTP details from a generation call. - */ -export type GenerateResponse = { id: string | null, timestamp: string | null, model_id: string | null, headers: { [key in string]: string } | null, body: JsonValue | null, }; diff --git a/bindings/node/src/types/GenerateResult.ts b/bindings/node/src/types/GenerateResult.ts index 5dafcbfd9..70e2ef62c 100644 --- a/bindings/node/src/types/GenerateResult.ts +++ b/bindings/node/src/types/GenerateResult.ts @@ -4,6 +4,8 @@ import type { GenerateContent } from "./GenerateContent"; import type { GeneratedFile } from "./GeneratedFile"; import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; import type { RawToolCall } from "./RawToolCall"; +import type { RequestInfo } from "./RequestInfo"; +import type { ResponseInfo } from "./ResponseInfo"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; import type { JsonValue } from "./serde_json/JsonValue"; @@ -11,4 +13,32 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Result of `LanguageModel::do_generate` (non-streaming). */ -export type GenerateResult = { content: Array>, finish_reason: FinishReason, usage: Usage, warnings: Array, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: { id: string | null, timestamp: string | null, model_id: string | null, body?: JsonValue | null, } | null, request_body: JsonValue | null, response_headers: { [key in string]: string } | null, }; +export type GenerateResult = { +/** + * Generated content items (text, tool calls, etc.). + */ +content: Array>, +/** + * Why generation stopped. + */ +finish_reason: FinishReason, +/** + * Token usage. + */ +usage: Usage, +/** + * Warnings issued by the provider. + */ +warnings: Array, +/** + * Provider-specific metadata. + */ +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +/** + * Optional request information for telemetry and debugging. + */ +request: RequestInfo | null, +/** + * Optional response metadata, headers, and body. + */ +response: ResponseInfo | null, }; diff --git a/bindings/node/src/types/GenerateTextResult.ts b/bindings/node/src/types/GenerateTextResult.ts index 1cfc38b31..98b9ac920 100644 --- a/bindings/node/src/types/GenerateTextResult.ts +++ b/bindings/node/src/types/GenerateTextResult.ts @@ -6,7 +6,8 @@ import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; import type { ReasoningFileOutput } from "./ReasoningFileOutput"; import type { ReasoningPart } from "./ReasoningPart"; -import type { ResponseMetadata } from "./ResponseMetadata"; +import type { RequestInfo } from "./RequestInfo"; +import type { ResponseInfo } from "./ResponseInfo"; import type { Source } from "./Source"; import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; @@ -17,71 +18,74 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * Result of `generate_text` (user-facing). */ -export type GenerateTextResult = { +export type GenerateTextResult = { /** * Ordered generated content with parsed tool calls. */ -content?: Array>, +content?: Array>, /** * The generated text (concatenated from all text content parts). */ -text: string, +text: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +tool_calls: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finish_reason: FinishReason, /** * Token usage. */ -usage: Usage, +usage: Usage, /** * Warnings from the provider. */ -warnings: Array, +warnings: Array, /** * Raw provider result (for advanced use). */ -raw: GenerateResult, +raw: GenerateResult, /** * Reasoning / thinking segments from the model. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text (convenience for `reasoning.iter().map(text).join("")`). */ -reasoning_text: string, +reasoning_text: string, /** * Sources / citations (search-preview models). */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Assistant messages ready to append to the prompt for the next turn * (solves the multi-turn "manually build assistant message" footgun). */ -response_messages: Array, +response_messages: Array, /** * The raw provider-specific finish reason string (e.g. "stop", * "end_turn", "safety"). Useful when `finish_reason.unified` is `Other`. */ -raw_finish_reason: string | null, +raw_finish_reason: string | null, /** * Provider-specific metadata (e.g. Anthropic cache info). Mirrored from * `raw.provider_metadata` for top-level convenience. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** - * Response metadata (id, timestamp, model_id). Mirrored from - * `raw.response` for top-level convenience. + * Request information from the provider. */ -response: ResponseMetadata, +request: RequestInfo, +/** + * Response metadata, headers, and body from the provider. + */ +response: ResponseInfo, /** * Total token usage across all steps. In single-step mode (aimux's * default), `total_usage` equals `usage`. Provided for AI SDK parity. diff --git a/bindings/node/src/types/InputRecord.ts b/bindings/node/src/types/InputRecord.ts index b7444ecf1..12ea93e86 100644 --- a/bindings/node/src/types/InputRecord.ts +++ b/bindings/node/src/types/InputRecord.ts @@ -10,7 +10,7 @@ export type InputRecord = { operation?: RecordingOperation, /** * 完整 prompt(消息数组,含 ContentPart::Image 等多模态)。 */ -prompt: Array, +prompt: Array, /** * 序列化的 CallOptions(abort_signal/call_id 已 serde skip); * headers/provider_options/body_overrides 已递归脱敏。 diff --git a/bindings/node/src/types/OutcomeRecord.ts b/bindings/node/src/types/OutcomeRecord.ts index fb13110d6..38777b88a 100644 --- a/bindings/node/src/types/OutcomeRecord.ts +++ b/bindings/node/src/types/OutcomeRecord.ts @@ -5,16 +5,16 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** * 最终结果摘要。 */ -export type OutcomeRecord = { +export type OutcomeRecord = { /** * Normalized decision result, independent of a provider's wire format. */ -decision_result?: JsonValue | null, status: OutcomeStatus, finish_reason: string | null, error: string | null, +decision_result?: JsonValue | null, status: OutcomeStatus, finish_reason: string | null, error: string | null, /** * Lossless structured domain error. In particular, `RetryError.errors` * keeps the complete attempt history rather than only its display text. */ -error_value: JsonValue | null, +error_value: JsonValue | null, /** * 序列化的 Usage。 */ diff --git a/bindings/node/src/types/ResponseInfo.ts b/bindings/node/src/types/ResponseInfo.ts index 44514337c..52103ad78 100644 --- a/bindings/node/src/types/ResponseInfo.ts +++ b/bindings/node/src/types/ResponseInfo.ts @@ -11,6 +11,10 @@ import type { JsonValue } from "./serde_json/JsonValue"; * `Option`. */ export type ResponseInfo = { +/** + * ID of the generated response, when supplied by the provider. + */ +id?: string | null, /** * Timestamp for the start of the generated response (ISO 8601 string). */ diff --git a/bindings/node/src/types/StreamPart.ts b/bindings/node/src/types/StreamPart.ts index a7e850989..03c3f862f 100644 --- a/bindings/node/src/types/StreamPart.ts +++ b/bindings/node/src/types/StreamPart.ts @@ -2,11 +2,10 @@ import type { AiMuxError } from "./AiMuxError"; import type { FinishReason } from "./FinishReason"; import type { GeneratedFile } from "./GeneratedFile"; -import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { RawToolApprovalRequest } from "./RawToolApprovalRequest"; +import type { RawToolCall } from "./RawToolCall"; import type { ResponseMetadata } from "./ResponseMetadata"; import type { Source } from "./Source"; -import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; -import type { ToolCall } from "./ToolCall"; import type { ToolResult } from "./ToolResult"; import type { Usage } from "./Usage"; import type { Warning } from "./Warning"; @@ -31,7 +30,7 @@ dynamic?: boolean | null, /** * Optional title for the tool call. */ -title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": RawToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": GeneratedFile } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": RawToolApprovalRequest } | { "ReasoningStart": { id: string, /** * Provider-specific metadata (e.g. xAI `itemId`). */ diff --git a/bindings/node/src/types/StreamResponse.ts b/bindings/node/src/types/StreamResponse.ts deleted file mode 100644 index 8edb92db1..000000000 --- a/bindings/node/src/types/StreamResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * HTTP details from a streaming call. - */ -export type StreamResponse = { headers: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/StreamResponseInfo.ts b/bindings/node/src/types/StreamResponseInfo.ts new file mode 100644 index 000000000..8426efa47 --- /dev/null +++ b/bindings/node/src/types/StreamResponseInfo.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response information returned when starting a language-model stream. + */ +export type StreamResponseInfo = { headers: { [key in string]: string } | null, }; diff --git a/bindings/node/src/types/StreamTextResultAggregated.ts b/bindings/node/src/types/StreamTextResultAggregated.ts index 305eacfb0..bf8b91600 100644 --- a/bindings/node/src/types/StreamTextResultAggregated.ts +++ b/bindings/node/src/types/StreamTextResultAggregated.ts @@ -5,7 +5,8 @@ import type { GeneratedFile } from "./GeneratedFile"; import type { ModelMessage } from "./ModelMessage"; import type { ReasoningFileOutput } from "./ReasoningFileOutput"; import type { ReasoningPart } from "./ReasoningPart"; -import type { ResponseMetadata } from "./ResponseMetadata"; +import type { RequestInfo } from "./RequestInfo"; +import type { ResponseInfo } from "./ResponseInfo"; import type { Source } from "./Source"; import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; import type { ToolCall } from "./ToolCall"; @@ -18,64 +19,68 @@ import type { JsonValue } from "./serde_json/JsonValue"; * `GenerateTextResult`'s user-facing fields (without `raw`, since streaming * has no `GenerateResult` equivalent). */ -export type StreamTextResultAggregated = { +export type StreamTextResultAggregated = { /** * Ordered content from the last generation step. */ -content?: Array>, +content?: Array>, /** * The generated text (concatenated `TextDelta`). */ -text: string, +text: string, /** * Reasoning / thinking segments. */ -reasoning: Array, +reasoning: Array, /** * Concatenated reasoning text. */ -reasoning_text: string, +reasoning_text: string, /** * Tool calls requested by the model. */ -tool_calls: Array, +tool_calls: Array, /** * Sources / citations. */ -sources: Array, +sources: Array, /** * Files generated by the model. */ -files: Array, +files: Array, /** * Why generation stopped. */ -finish_reason: FinishReason, +finish_reason: FinishReason, /** * Raw provider-specific finish reason string. */ -raw_finish_reason: string | null, +raw_finish_reason: string | null, /** * Token usage. */ -usage: Usage, +usage: Usage, /** * Total token usage across all steps. In single-step mode (aimux's * default), `total_usage` equals `usage`. Provided for AI SDK parity. */ -total_usage: Usage, +total_usage: Usage, /** * Warnings from the provider. */ -warnings: Array, +warnings: Array, /** * Provider-specific metadata from the Finish chunk. */ -provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, /** - * Response metadata (id, timestamp, model_id) if emitted by the stream. + * Request information from the provider. */ -response: ResponseMetadata | null, +request: RequestInfo, +/** + * Response metadata and headers from the last step. + */ +response: ResponseInfo, /** * Assistant messages ready to append for the next turn. */ diff --git a/bindings/node/src/types/TextStreamPart.ts b/bindings/node/src/types/TextStreamPart.ts new file mode 100644 index 000000000..7457a4b09 --- /dev/null +++ b/bindings/node/src/types/TextStreamPart.ts @@ -0,0 +1,42 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AiMuxError } from "./AiMuxError"; +import type { FinishReason } from "./FinishReason"; +import type { GeneratedFile } from "./GeneratedFile"; +import type { ReasoningFileOutput } from "./ReasoningFileOutput"; +import type { ResponseInfo } from "./ResponseInfo"; +import type { Source } from "./Source"; +import type { ToolApprovalRequestOutput } from "./ToolApprovalRequestOutput"; +import type { ToolCall } from "./ToolCall"; +import type { ToolResult } from "./ToolResult"; +import type { Usage } from "./Usage"; +import type { Warning } from "./Warning"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A public call-layer stream chunk, with parsed tool calls. + */ +export type TextStreamPart = { "TextStart": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "TextEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "StreamStart": { warnings: Array, } } | { "Finish": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "FinishStep": { finish_reason: FinishReason, usage: Usage, provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, response: ResponseInfo, } } | { "Error": { error: AiMuxError, } } | { "ToolInputStart": { id: string, tool_name: string, +/** + * Whether the tool call will be executed by the provider. + */ +provider_executed?: boolean | null, +/** + * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + */ +dynamic?: boolean | null, +/** + * Optional title for the tool call. + */ +title?: string | null, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputDelta": { id: string, delta: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolInputEnd": { id: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolCall": ToolCall } | { "ToolResult": ToolResult } | { "File": GeneratedFile } | { "ReasoningFile": ReasoningFileOutput } | { "Custom": { kind: string, provider_metadata?: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ToolApprovalRequest": ToolApprovalRequestOutput } | { "ReasoningStart": { id: string, +/** + * Provider-specific metadata (e.g. xAI `itemId`). + */ +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningDelta": { id: string, delta: string, +/** + * Provider-specific metadata (e.g. xAI `itemId`). + */ +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "ReasoningEnd": { id: string, +/** + * Provider-specific metadata (e.g. xAI `itemId`, `reasoningEncryptedContent`). + */ +provider_metadata: { [key in string]: { [key in string]: JsonValue } } | null, } } | { "Source": Source } | { "Raw": { raw_value: JsonValue, } }; diff --git a/bindings/python/python/aimux/__init__.py b/bindings/python/python/aimux/__init__.py index e228d77fa..de446b67c 100644 --- a/bindings/python/python/aimux/__init__.py +++ b/bindings/python/python/aimux/__init__.py @@ -15,6 +15,7 @@ RetryError, JSONParseError, InvalidResponseDataError, + NoOutputGeneratedError, NoSuchToolError, InvalidToolInputError, ToolCallRepairError, @@ -93,6 +94,7 @@ "RetryError", "JSONParseError", "InvalidResponseDataError", + "NoOutputGeneratedError", "NoSuchToolError", "InvalidToolInputError", "ToolCallRepairError", diff --git a/bindings/python/python/aimux/wrapper.py b/bindings/python/python/aimux/wrapper.py index 0e3710f24..7b54aac03 100644 --- a/bindings/python/python/aimux/wrapper.py +++ b/bindings/python/python/aimux/wrapper.py @@ -67,6 +67,7 @@ "StreamTextResultAggregated", "GenerateResult", "GenerateResponseMetadata", + "RequestInfo", # functions "generate_text", "stream_text", @@ -154,6 +155,11 @@ class ResponseMetadata(BaseModel): model_id: Optional[str] = None +class GenerateResponseMetadata(ResponseMetadata): + headers: Optional[Dict[str, str]] = None + body: Optional[Any] = None + + class ToolCall(BaseModel): """A tool call requested by the model (Rust ``tool::ToolCall``).""" @@ -607,11 +613,9 @@ class _SPReasoningEnd(BaseModel): provider_metadata: Optional[Any] = None -class _SPResponseMetadata(BaseModel): - type: Literal["ResponseMetadata"] - id: Optional[str] = None - timestamp: Optional[str] = None - model_id: Optional[str] = None +class _SPFinishStep(_SPFinish): + type: Literal["FinishStep"] + response: GenerateResponseMetadata @@ -687,7 +691,7 @@ def _int_to_ext(self, handler): _SPReasoningStart, _SPReasoningDelta, _SPReasoningEnd, - _SPResponseMetadata, + _SPFinishStep, _ContentSource, _SPRaw, _ContentCustom, @@ -1056,7 +1060,9 @@ class GenerateTextOptions(BaseModel): """ -class GenerateResponseMetadata(ResponseMetadata): + + +class RequestInfo(BaseModel): body: Optional[Any] = None @@ -1068,9 +1074,8 @@ class GenerateResult(BaseModel): usage: Usage warnings: List[Warning] provider_metadata: Optional[Any] = None + request: Optional[RequestInfo] = None response: Optional[GenerateResponseMetadata] = None - request_body: Optional[Any] = None - response_headers: Optional[Dict[str, str]] = None class GenerateTextResult(BaseModel): @@ -1096,8 +1101,8 @@ class GenerateTextResult(BaseModel): # Provider-specific metadata (e.g. Anthropic cache info). Mirrored from # raw.provider_metadata for top-level convenience. Weak type (Dict). provider_metadata: Optional[Dict[str, Any]] = None - # Response metadata (id, timestamp, model_id). Mirrored from raw.response. - response: ResponseMetadata = Field(default_factory=ResponseMetadata) + request: RequestInfo = Field(default_factory=RequestInfo) + response: GenerateResponseMetadata = Field(default_factory=GenerateResponseMetadata) # Total token usage across all steps. In single-step mode (aimux's # default), equals usage. Provided for AI SDK parity. total_usage: Usage = Field(default_factory=Usage) @@ -1149,8 +1154,8 @@ class StreamTextResultAggregated(BaseModel): warnings: List[Warning] = Field(default_factory=list) # Provider-specific metadata from the Finish chunk. Weak type (Dict). provider_metadata: Optional[Dict[str, Any]] = None - # Response metadata (id, timestamp, model_id) if emitted by the stream. - response: Optional[ResponseMetadata] = None + request: RequestInfo = Field(default_factory=RequestInfo) + response: GenerateResponseMetadata = Field(default_factory=GenerateResponseMetadata) response_messages: List[ModelMessage] = Field(default_factory=list) diff --git a/bindings/python/src/error.rs b/bindings/python/src/error.rs index 3245887bd..8e317bbc3 100644 --- a/bindings/python/src/error.rs +++ b/bindings/python/src/error.rs @@ -30,6 +30,7 @@ use pyo3::types::{PyAnyMethods, PyList}; create_exception!(aimux, AimuxError, PyException, "AiMux failure"); create_exception!(aimux, APICallError, AimuxError, "API call failure"); +create_exception!(aimux, NoOutputGeneratedError, AimuxError, "No output generated"); create_exception!(aimux, RetryError, AimuxError, "Operation retry failure"); create_exception!( aimux, @@ -240,6 +241,7 @@ fn variant_instance<'py>(py: Python<'py>, e: &AiMuxError) -> PyResult py.get_type_bound::(), AiMuxError::Retry(_) => py.get_type_bound::(), AiMuxError::JsonParse(_) => py.get_type_bound::(), + AiMuxError::NoOutputGenerated(_) => py.get_type_bound::(), AiMuxError::InvalidResponseData(_) | AiMuxError::ToolCallNotFoundForApproval { .. } => { py.get_type_bound::() } @@ -371,6 +373,7 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("APICallError", py.get_type_bound::())?; m.add("RetryError", py.get_type_bound::())?; m.add("JSONParseError", py.get_type_bound::())?; + m.add("NoOutputGeneratedError", py.get_type_bound::())?; m.add( "InvalidResponseDataError", py.get_type_bound::(), diff --git a/bindings/swift/Sources/Aimux/Aimux.swift b/bindings/swift/Sources/Aimux/Aimux.swift index 9904e107b..a8b531653 100644 --- a/bindings/swift/Sources/Aimux/Aimux.swift +++ b/bindings/swift/Sources/Aimux/Aimux.swift @@ -11,7 +11,7 @@ import Foundation // // Every fallible C function returns `aimux_error_t *` (`OpaquePointer?`): // NULL = success (result in the trailing out-param), non-NULL = failure. The -// unified code is AiMuxError (1...17), RecordingError (100...105), or a C ABI +// unified code is AiMuxError (1...18), RecordingError (100...105), or a C ABI // failure (200...206). The three `expect*` decoders copy the relevant fields, release // it with `aimux_error_free` (exactly once) and return the Swift error // to throw. Errors are not handles: never `aimux_drop_handle` one. @@ -44,7 +44,7 @@ func expectFfiError(_ e: OpaquePointer, context: String) -> any Error { return invariant("aimux ffi: \(context): \(message)") } -/// Decode a returned error from an `[AiMuxError]` call: 1...17 becomes +/// Decode a returned error from an `[AiMuxError]` call: 1...18 becomes /// `AimuxError`; 200...206 is decoded by `expectFfiError`. Frees `e` once. func expectAimuxError(_ e: OpaquePointer, context: String) -> any Error { let code = aimux_error_code(e) @@ -141,6 +141,7 @@ public enum RetryErrorReason: String, Equatable, Sendable { /// } /// ``` public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatable, Sendable { + case noOutputGenerated(message: String, status: Int, retryMs: Int64, retryable: Bool) case jsonParse(message: String, status: Int, retryMs: Int64, retryable: Bool) case invalidResponseData(message: String, status: Int, retryMs: Int64, retryable: Bool) case invalidArgument(message: String, status: Int, retryMs: Int64, retryable: Bool) @@ -185,7 +186,8 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl /// The C-derived payload. private var payload: (message: String, status: Int, retryMs: Int64, retryable: Bool) { switch self { - case .jsonParse(let m, let s, let r, let t), + case .noOutputGenerated(let m, let s, let r, let t), + .jsonParse(let m, let s, let r, let t), .invalidResponseData(let m, let s, let r, let t), .invalidArgument(let m, let s, let r, let t), .invalidPrompt(let m, let s, let r, let t), @@ -212,6 +214,7 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl public var code: Int32 { let c: aimux_error_code_t switch self { + case .noOutputGenerated: c = AIMUX_E_NO_OUTPUT_GENERATED case .jsonParse: c = AIMUX_E_JSON_PARSE case .invalidResponseData: c = AIMUX_E_INVALID_RESPONSE_DATA case .invalidArgument: c = AIMUX_E_INVALID_ARGUMENT @@ -385,6 +388,8 @@ public enum AimuxError: Error, LocalizedError, CustomStringConvertible, Equatabl let message = rawMsg.isEmpty ? "aimux: operation failed" : rawMsg switch code { + case AIMUX_E_NO_OUTPUT_GENERATED: + return .noOutputGenerated(message: message, status: status, retryMs: retryMs, retryable: retryable) case AIMUX_E_JSON_PARSE: return .jsonParse(message: message, status: status, retryMs: retryMs, retryable: retryable) case AIMUX_E_INVALID_RESPONSE_DATA: diff --git a/bindings/swift/Sources/Aimux/Types.swift b/bindings/swift/Sources/Aimux/Types.swift index 27167fe0b..8b308b546 100644 --- a/bindings/swift/Sources/Aimux/Types.swift +++ b/bindings/swift/Sources/Aimux/Types.swift @@ -1003,17 +1003,24 @@ public enum GenerateContent: Codable, Equatable { } } +public struct GenerateRequest: Codable, Equatable { + public var body: JSONValue? + public init(body: JSONValue? = nil) { self.body = body } +} + public struct GenerateResponseMetadata: Codable, Equatable { public var id: String? public var timestamp: String? public var modelId: String? - public var body: JSONValue? public var headers: [String: String]? + public var body: JSONValue? enum CodingKeys: String, CodingKey { - case id, timestamp, body, headers, modelId = "model_id" + case id, timestamp, headers, body, modelId = "model_id" } - public init(id: String? = nil, timestamp: String? = nil, modelId: String? = nil, body: JSONValue? = nil, headers: [String: String]? = nil) { - self.id = id; self.timestamp = timestamp; self.modelId = modelId; self.body = body; self.headers = headers + public init(id: String? = nil, timestamp: String? = nil, modelId: String? = nil, + headers: [String: String]? = nil, body: JSONValue? = nil) { + self.id = id; self.timestamp = timestamp; self.modelId = modelId + self.headers = headers; self.body = body } } @@ -1025,8 +1032,7 @@ public struct GenerateResult: Codable, Equatable { public var warnings: [Warning]? public var providerMetadata: JSONValue? public var response: GenerateResponseMetadata? - public var requestBody: JSONValue? - public var responseHeaders: [String: String]? + public var request: GenerateRequest? enum CodingKeys: String, CodingKey { case content @@ -1034,17 +1040,15 @@ public struct GenerateResult: Codable, Equatable { case usage, warnings case providerMetadata = "provider_metadata" case response - case requestBody = "request_body" - case responseHeaders = "response_headers" + case request } public init(content: [GenerateContent], finishReason: FinishReason? = nil, usage: Usage? = nil, warnings: [Warning]? = nil, providerMetadata: JSONValue? = nil, - response: GenerateResponseMetadata? = nil, requestBody: JSONValue? = nil, - responseHeaders: [String: String]? = nil) { + response: GenerateResponseMetadata? = nil, request: GenerateRequest? = nil) { self.content = content; self.finishReason = finishReason; self.usage = usage self.warnings = warnings; self.providerMetadata = providerMetadata - self.response = response; self.requestBody = requestBody; self.responseHeaders = responseHeaders + self.response = response; self.request = request } }